Files
dingtalk-workspace-cli/internal/helpers/leaf_test.go
玉澜andCursor f793d980b5 fix(test): align leaf BuildArgs expectations with required-after-transform
TestLeafArgsOmitsEmptyAndNonPositive called BuildArgs with unset required
CSV flags; after rejecting empty required transforms that path correctly
errors. Satisfy required flags in the omit-empty case and cover the new
RequiredError / scalar-transform branches for the macOS coverage gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 21:54:02 +08:00

660 lines
21 KiB
Go

// 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"
"fmt"
"io"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type deferConfirmTestCaller struct {
calls int
}
func (c *deferConfirmTestCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
c.calls++
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
}
func (*deferConfirmTestCaller) Format() string { return "json" }
func (*deferConfirmTestCaller) DryRun() bool { return false }
func (*deferConfirmTestCaller) Fields() string { return "" }
func (*deferConfirmTestCaller) JQ() string { return "" }
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: LeafInt, 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 := corecmd.ValidateRequired(cmd, leafTestSpec().Flags)
if err == nil || !strings.Contains(err.Error(), "missing required flag(s): --content") {
t.Fatalf("leafValidateRequired() = %v, want missing --content", err)
}
}
func TestLeafValidateRequiredSatisfiedByAlias(t *testing.T) {
// 声明的回退语义:只传兼容别名时,普通 Required 也视为已提供。
spec := LeafSpec{
Use: "send", Tool: "send_thing",
Flags: []LeafFlag{
{Name: "content", Usage: "内容", Required: true, Aliases: []string{"remark"}},
},
}
cmd := NewLeafCommand(spec)
if err := cmd.Flags().Set("remark", "仅别名"); err != nil {
t.Fatal(err)
}
if err := corecmd.ValidateRequired(cmd, spec.Flags); err != nil {
t.Fatalf("leafValidateRequired() = %v, want nil (alias satisfies required)", err)
}
}
func TestLeafValidateRequiredAliasAbsentStillFails(t *testing.T) {
// 主 flag 与别名都缺失时,仍按主 flag 名报统一错误。
spec := LeafSpec{
Use: "send", Tool: "send_thing",
Flags: []LeafFlag{
{Name: "content", Usage: "内容", Required: true, Aliases: []string{"remark"}},
},
}
cmd := NewLeafCommand(spec)
err := corecmd.ValidateRequired(cmd, spec.Flags)
if err == nil || !strings.Contains(err.Error(), "missing required flag(s): --content") {
t.Fatalf("leafValidateRequired() = %v, want missing --content", err)
}
}
func TestLeafValidateRequiredTrimWhitespaceOnlyFails(t *testing.T) {
// Trim 声明下纯空白值在 required 校验中视为空。
spec := LeafSpec{
Use: "send", Tool: "send_thing",
Flags: []LeafFlag{
{Name: "content", Usage: "内容", Required: true, Trim: true},
},
}
cmd := NewLeafCommand(spec)
if err := cmd.Flags().Set("content", " "); err != nil {
t.Fatal(err)
}
err := corecmd.ValidateRequired(cmd, spec.Flags)
if err == nil || !strings.Contains(err.Error(), "missing required flag(s): --content") {
t.Fatalf("leafValidateRequired() = %v, want whitespace-only treated as missing", err)
}
}
func TestLeafValidateRequiredEnvFallback(t *testing.T) {
cmd := NewLeafCommand(leafTestSpec())
err := corecmd.ValidateRequired(cmd, leafTestSpec().Flags)
// 普通组(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 = corecmd.ValidateRequired(cmd, leafTestSpec().Flags)
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 := corecmd.ValidateRequired(cmd, leafTestSpec().Flags); 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 := corecmd.BuildArgs(cmd, leafTestSpec().Flags)
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"] != 10 {
t.Fatalf("cursor = %v (%T), want int(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())
// Satisfy Required flags first: BuildArgs now rejects Required transforms that
// collapse to empty (unset CSV), matching runtime behavior after ValidateRequired.
t.Setenv("DWS_LEAF_TEST_TOKEN", "tok")
if err := cmd.Flags().Set("users", "u1"); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("content", "hello"); err != nil {
t.Fatal(err)
}
args, err := corecmd.BuildArgs(cmd, leafTestSpec().Flags)
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 zero", args["cursor"])
}
if v, present := args["accessToken"]; !present || v != "tok" {
t.Fatalf("accessToken = %v/%v, want env tok", 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) {
// The custom RunE replaces dispatch, not the declared contract.
called := false
newSend := func() *cobra.Command {
called = false
spec := leafTestSpec()
spec.RunE = func(cmd *cobra.Command, args []string) error {
called = true
return nil
}
return NewLeafCommand(spec)
}
unsatisfied := newSend()
err := unsatisfied.RunE(unsatisfied, nil)
if err == nil || !strings.Contains(err.Error(), "missing required flag(s)") {
t.Fatalf("custom RunE must not bypass declared required flags, got %v", err)
}
if called {
t.Fatal("custom RunE ran despite an unsatisfied declaration")
}
t.Setenv("DWS_LEAF_TEST_TOKEN", "tok")
satisfied := newSend()
if err := satisfied.Flags().Set("users", "u1"); err != nil {
t.Fatal(err)
}
if err := satisfied.Flags().Set("content", "hello"); err != nil {
t.Fatal(err)
}
if err := satisfied.RunE(satisfied, 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 := corecmd.BuildArgs(cmd, spec.Flags)
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 := corecmd.BuildArgs(cmd, spec.Flags)
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 = corecmd.BuildArgs(cmd, spec.Flags)
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 TestDeclareLeafMetadataInstallsConfirmSafetyForUserRequired(t *testing.T) {
// With Validate: confirm runs at RunE entry (after PreRunE), so inner
// must not execute without --yes.
called := false
cmd := &cobra.Command{
Use: "delete",
RunE: func(cmd *cobra.Command, args []string) error {
called = true
return nil
},
}
cmd.Flags().Bool("yes", false, "")
cmd.Flags().Bool("dry-run", false, "")
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "destructive", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
Validate: func(*cobra.Command, []string) error { return nil },
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "dev",
Name: "create_thing",
CanonicalPath: "dev.create_thing",
CLIPath: "dev create",
PrimaryCLIPath: "dev create",
},
Description: "test delete",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "test fixture for ConfirmSafety wrap",
},
Selection: contract.SelectionSpec{
AgentSummary: "test delete",
UseWhen: []string{"test"},
AvoidWhen: []string{"never"},
Examples: []string{"dws delete --yes"},
},
},
})
if !HasContractConfirmSafety(cmd) || !HasContractValidate(cmd) {
t.Fatal("expected contract ConfirmSafety + Validate annotations")
}
cmd.SetIn(strings.NewReader(""))
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := cmd.Execute(); err == nil {
t.Fatal("want confirmation_required without --yes")
}
if called {
t.Fatal("inner RunE must not run before confirmation")
}
if err := cmd.Flags().Set("yes", "true"); err != nil {
t.Fatal(err)
}
called = false
if err := cmd.Execute(); err != nil {
t.Fatalf("with --yes: %v", err)
}
if !called {
t.Fatal("inner RunE must run after --yes")
}
}
func TestDeclareLeafMetadataDefersConfirmUntilCallTool(t *testing.T) {
// Without Validate: RunE-local checks run before ConfirmSafety; the gate
// fires on the first MCP CallTool.
testseam.Protect(t, &deps)
concrete := &deferConfirmTestCaller{}
InitDeps(concrete)
deps.Out.w = io.Discard
cmd := &cobra.Command{
Use: "delete",
RunE: func(c *cobra.Command, args []string) error {
id, _ := c.Flags().GetString("id")
if strings.TrimSpace(id) == "" {
return fmt.Errorf("flag --id is required")
}
_, err := deps.Caller.CallTool(c.Context(), "test", "delete_thing", map[string]any{"id": id})
return err
},
}
cmd.Flags().Bool("yes", false, "")
cmd.Flags().Bool("dry-run", false, "")
cmd.Flags().String("id", "", "")
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "destructive", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "dev",
Name: "create_thing",
CanonicalPath: "dev.create_thing",
CLIPath: "dev create",
PrimaryCLIPath: "dev create",
},
Description: "test delete",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "test fixture for deferred ConfirmSafety",
},
Selection: contract.SelectionSpec{
AgentSummary: "test delete",
UseWhen: []string{"test"},
AvoidWhen: []string{"never"},
Examples: []string{"dws delete --id x --yes"},
},
},
})
cmd.SetIn(strings.NewReader(""))
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "flag --id is required") {
t.Fatalf("missing id: %v, want RunE validation before confirm", err)
}
if concrete.calls != 0 {
t.Fatalf("CallTool calls = %d, want 0 when validation fails", concrete.calls)
}
if err := cmd.Flags().Set("id", "x"); err != nil {
t.Fatal(err)
}
err = cmd.Execute()
if err == nil || (!strings.Contains(err.Error(), "confirmation_required") && !strings.Contains(err.Error(), "需要用户确认")) {
t.Fatalf("valid args without --yes: %v, want confirmation_required", err)
}
if concrete.calls != 0 {
t.Fatalf("CallTool calls = %d, want 0 before confirmation", concrete.calls)
}
}
func TestDeclareLeafMetadataValidateRunsBeforeConfirmSafety(t *testing.T) {
// RFC §5.1 / §5.6: local validation must precede Risk confirmation.
ran := false
cmd := &cobra.Command{
Use: "mutate",
RunE: func(*cobra.Command, []string) error {
ran = true
return nil
},
}
cmd.Flags().Bool("yes", false, "")
cmd.Flags().Bool("dry-run", false, "")
cmd.Flags().String("id", "", "")
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Validate: func(c *cobra.Command, args []string) error {
id, _ := c.Flags().GetString("id")
if strings.TrimSpace(id) == "" {
return fmt.Errorf("flag --id is required")
}
return nil
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "dev",
Name: "create_thing",
CanonicalPath: "dev.create_thing",
CLIPath: "dev create",
PrimaryCLIPath: "dev create",
},
Description: "test mutate",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "test fixture for Validate-before-ConfirmSafety",
},
Selection: contract.SelectionSpec{
AgentSummary: "test mutate",
UseWhen: []string{"test"},
AvoidWhen: []string{"never"},
Examples: []string{"dws mutate --id x --yes"},
},
},
})
cmd.SetIn(strings.NewReader(""))
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "flag --id is required") {
t.Fatalf("Execute() error = %v, want Validate failure before confirmation", err)
}
if strings.Contains(err.Error(), "confirmation_required") || strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("Validate must win over ConfirmSafety, got %v", err)
}
if ran {
t.Fatal("RunE must not run when Validate fails")
}
}
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 := corecmd.BuildArgs(cmd, spec.Flags)
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"])
}
}