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>
660 lines
21 KiB
Go
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"])
|
|
}
|
|
}
|