509 lines
16 KiB
Go
509 lines
16 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 builtin_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/builtin"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// fakeCaller implements edition.ToolCaller. It records the (product, tool, args)
|
|
// of the last CallTool so the coverage test can assert what MCP call a shortcut
|
|
// assembled — WITHOUT any network I/O or side effects. This lets us exercise
|
|
// every shortcut, including write/delete commands, safely.
|
|
type fakeCaller struct {
|
|
called bool
|
|
product string
|
|
tool string
|
|
args map[string]any
|
|
dryRun bool
|
|
payload int
|
|
}
|
|
|
|
func (f *fakeCaller) reset() { f.called, f.product, f.tool, f.args = false, "", "", nil }
|
|
|
|
func (f *fakeCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
|
f.called, f.product, f.tool, f.args = true, product, tool, args
|
|
payload, err := json.Marshal(fakePayload(f.payload))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: string(payload)}}}, nil
|
|
}
|
|
func (f *fakeCaller) Format() string { return "json" }
|
|
func (f *fakeCaller) DryRun() bool { return f.dryRun }
|
|
func (f *fakeCaller) Fields() string { return "" }
|
|
func (f *fakeCaller) JQ() string { return "" }
|
|
|
|
// realToolSet scans the helper sources (the ground truth) and returns the set of
|
|
// snake_case identifiers found there. Every tool a shortcut invokes must appear
|
|
// in this set; anything else would be a hallucinated tool name.
|
|
func realToolSet(t *testing.T) map[string]bool {
|
|
t.Helper()
|
|
files, err := filepath.Glob("../../../internal/helpers/*.go")
|
|
if err != nil || len(files) == 0 {
|
|
t.Fatalf("cannot locate helper sources: %v (found %d)", err, len(files))
|
|
}
|
|
// Two oracles unioned:
|
|
// - every ASCII word token in the helper sources (guarantees any
|
|
// snake_case / camelCase tool name present anywhere is captured);
|
|
// - quoted literals containing CJK, to cover Chinese const tool names
|
|
// (e.g. minutes' "执行听记指令-发起AI听记录音").
|
|
wordRe := regexp.MustCompile(`[A-Za-z][A-Za-z0-9_]{2,}`)
|
|
cjkRe := regexp.MustCompile(`"([^"]*\p{Han}[^"]*)"`)
|
|
set := map[string]bool{}
|
|
for _, f := range files {
|
|
data, err := os.ReadFile(f)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
s := string(data)
|
|
for _, m := range wordRe.FindAllString(s, -1) {
|
|
set[m] = true
|
|
}
|
|
for _, m := range cjkRe.FindAllStringSubmatch(s, -1) {
|
|
set[m[1]] = true
|
|
}
|
|
}
|
|
return set
|
|
}
|
|
|
|
// synthArgs builds a plausible argument vector for a shortcut: a value for every
|
|
// declared flag (enum flags get their first allowed value), plus --yes to skip
|
|
// confirmation on write/high-risk commands.
|
|
func synthArgs(s shortcut.Shortcut) []string {
|
|
args := []string{s.Service, s.Command}
|
|
skipped := map[string]bool{}
|
|
for _, constraint := range s.Constraints {
|
|
if constraint.Kind != shortcut.ConstraintExactlyOne && constraint.Kind != shortcut.ConstraintMutuallyExclusive {
|
|
continue
|
|
}
|
|
for _, name := range constraint.Flags[1:] {
|
|
skipped[name] = true
|
|
}
|
|
}
|
|
for _, f := range s.Flags {
|
|
if skipped[f.Name] {
|
|
continue
|
|
}
|
|
switch {
|
|
case len(f.Enum) > 0:
|
|
args = append(args, "--"+f.Name, f.Enum[0])
|
|
case f.Type == shortcut.FlagBool:
|
|
args = append(args, "--"+f.Name)
|
|
default:
|
|
args = append(args, "--"+f.Name, synthFlagValue(s, f))
|
|
}
|
|
}
|
|
return args
|
|
}
|
|
|
|
func synthFlagValue(s shortcut.Shortcut, f shortcut.Flag) string {
|
|
name := strings.ToLower(f.Name)
|
|
desc := strings.ToLower(f.Desc)
|
|
if f.Type == shortcut.FlagInt {
|
|
switch name {
|
|
case "from":
|
|
return "9"
|
|
case "to":
|
|
return "18"
|
|
default:
|
|
return "1"
|
|
}
|
|
}
|
|
if name == "pair" {
|
|
return "原文=>替换"
|
|
}
|
|
if name == "category-ids" {
|
|
return "1"
|
|
}
|
|
if name == "role-types" {
|
|
return "creator"
|
|
}
|
|
if name == "num" {
|
|
return "1"
|
|
}
|
|
if name == "type" && s.Service == "attendance" && s.Command == "+get-approve-template" {
|
|
return "leave"
|
|
}
|
|
if s.Service == "attendance" {
|
|
switch {
|
|
case s.Command == "+list-approve" && name == "types":
|
|
return "overtime"
|
|
case s.Command == "+import-schedule" && name == "schedules":
|
|
return `[{"userId":"user-1","workDate":"2026-04-01 09:00:00","classId":1,"isRest":"N"}]`
|
|
case s.Command == "+create-class" && name == "class-vo":
|
|
return `{"sections":[{"times":[{"checkType":"OnDuty","checkTime":"09:00","across":0},{"checkType":"OffDuty","checkTime":"18:00","across":0}]}]}`
|
|
case s.Command == "+create-group" && name == "group-vo":
|
|
return `{"defaultClassId":1,"workDayClassList":[0,1,1,1,1,1,0]}`
|
|
case s.Command == "+update-group" && name == "class-ids":
|
|
return `[1]`
|
|
case s.Command == "+query-report-data" && name == "columns":
|
|
return "1"
|
|
case s.Command == "+update-leave-type" && name == "visibility-rules":
|
|
return `[{"type":"dept","visible":["-1"]}]`
|
|
}
|
|
}
|
|
if strings.Contains(desc, "json") || strings.Contains(name, "config") || strings.HasSuffix(name, "-vo") {
|
|
switch name {
|
|
case "records":
|
|
return `[{"recordId":"rec-1","cells":{"name":"test"}}]`
|
|
case "schedules":
|
|
return `[{}]`
|
|
case "filters", "sub-roles", "visibility-rules":
|
|
return `[]`
|
|
default:
|
|
return `{}`
|
|
}
|
|
}
|
|
if name == "start" || name == "end" || name == "date" {
|
|
if s.Service == "attendance" {
|
|
if strings.Contains(desc, "hh:mm:ss") || strings.Contains(desc, "时分秒") {
|
|
if name == "end" {
|
|
return "2026-04-01 18:00:00"
|
|
}
|
|
return "2026-04-01 09:00:00"
|
|
}
|
|
if name == "end" {
|
|
return "2026-04-02"
|
|
}
|
|
return "2026-04-01"
|
|
}
|
|
if name == "end" {
|
|
return "2026-04-01T18:00:00+08:00"
|
|
}
|
|
return "2026-04-01T09:00:00+08:00"
|
|
}
|
|
if name == "modified-start" {
|
|
return "2026-04-01T09:00:00+08:00"
|
|
}
|
|
if name == "modified-end" {
|
|
return "2026-04-01T18:00:00+08:00"
|
|
}
|
|
if strings.Contains(name, "email") {
|
|
return "user@example.com"
|
|
}
|
|
if strings.Contains(name, "url") {
|
|
return "https://example.com/file"
|
|
}
|
|
return "x"
|
|
}
|
|
|
|
func fakePayload(mode int) map[string]any {
|
|
item := map[string]any{
|
|
"id": "id-1", "uuid": "uuid-1", "taskUuid": "task-1", "taskId": "task-1",
|
|
"todoTaskId": "todo-1", "recordId": "rec-1", "baseId": "base-1", "tableId": "table-1",
|
|
"fieldId": "field-1", "viewId": "view-1", "userId": "user-1", "deptId": "dept-1",
|
|
"spaceId": "space-1", "nodeId": "node-1", "dentryUuid": "file-1", "eventId": "event-1",
|
|
"roomId": "room-1", "calendarId": "cal-1", "messageId": "msg-1", "reportId": "report-1",
|
|
"processInstanceId": "process-1", "openConversationId": "cid-1", "conversationId": "cid-1",
|
|
"name": "测试名称", "title": "测试标题", "subject": "测试主题", "summary": "测试摘要",
|
|
"description": "测试描述", "text": "测试内容", "content": "测试内容", "status": "NORMAL",
|
|
"type": "text", "email": "user@example.com", "mobile": "13800000000", "priority": 1,
|
|
"startTime": int64(1775005200000), "endTime": int64(1775037600000), "createTime": int64(1775005200000),
|
|
"planFinishDate": int64(1775037600000), "fileSize": int64(128), "cells": map[string]any{"name": "test"},
|
|
"sender": map[string]any{"userId": "user-1", "name": "测试用户"},
|
|
"creator": map[string]any{"userId": "user-1", "name": "测试用户"},
|
|
"owner": map[string]any{"userId": "user-1", "name": "测试用户"},
|
|
}
|
|
list := []any{item}
|
|
container := map[string]any{
|
|
"ok": true, "hasMore": false, "nextCursor": "", "nextToken": "", "total": 1,
|
|
}
|
|
for _, key := range []string{
|
|
"list", "items", "records", "users", "departments", "spaces", "tables", "fields", "views",
|
|
"messages", "events", "rooms", "calendars", "participants", "attendees", "approvals", "tasks",
|
|
"todos", "mails", "reports", "minutes", "documents", "files", "nodes", "groups", "members",
|
|
} {
|
|
container[key] = list
|
|
}
|
|
for key, value := range item {
|
|
container[key] = value
|
|
}
|
|
switch mode {
|
|
case 1:
|
|
container["result"] = list
|
|
container["data"] = list
|
|
return container
|
|
case 2:
|
|
return map[string]any{"ok": true, "result": container, "data": container}
|
|
case 3:
|
|
return map[string]any{"ok": true, "result": list, "data": container, "items": list, "list": list}
|
|
default:
|
|
return map[string]any{"ok": true}
|
|
}
|
|
}
|
|
|
|
func setOutputRecursive(cmd *cobra.Command, writer io.Writer) {
|
|
cmd.SetOut(writer)
|
|
cmd.SetErr(writer)
|
|
for _, child := range cmd.Commands() {
|
|
setOutputRecursive(child, writer)
|
|
}
|
|
}
|
|
|
|
func newCoverageRoot() *cobra.Command {
|
|
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
|
|
root.PersistentFlags().Bool("yes", false, "")
|
|
root.PersistentFlags().Bool("dry-run", false, "")
|
|
root.PersistentFlags().String("format", "json", "")
|
|
root.AddCommand(builtin.Commands()...)
|
|
setOutputRecursive(root, io.Discard)
|
|
return root
|
|
}
|
|
|
|
func silenceProcessOutput(t *testing.T) {
|
|
t.Helper()
|
|
file, err := os.Create(filepath.Join(t.TempDir(), "shortcut-output.log"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
oldStdout, oldStderr := os.Stdout, os.Stderr
|
|
os.Stdout, os.Stderr = file, file
|
|
t.Cleanup(func() {
|
|
os.Stdout, os.Stderr = oldStdout, oldStderr
|
|
_ = file.Close()
|
|
})
|
|
}
|
|
|
|
func TestAllShortcutConstraintsAreDiscoverableAndBound(t *testing.T) {
|
|
for _, s := range shortcut.All() {
|
|
name := s.Service + " " + s.Command
|
|
flags := make(map[string]bool, len(s.Flags))
|
|
for _, flag := range s.Flags {
|
|
flags[flag.Name] = true
|
|
}
|
|
hasCustom := false
|
|
for _, constraint := range s.Constraints {
|
|
if len(constraint.Flags) == 0 {
|
|
t.Errorf("%s: %s constraint has no flags", name, constraint.Kind)
|
|
}
|
|
for _, flag := range constraint.Flags {
|
|
if !flags[flag] {
|
|
t.Errorf("%s: %s constraint references unknown --%s", name, constraint.Kind, flag)
|
|
}
|
|
}
|
|
if constraint.Kind == shortcut.ConstraintCustom {
|
|
hasCustom = true
|
|
if strings.TrimSpace(constraint.Description) == "" {
|
|
t.Errorf("%s: custom constraint has no description", name)
|
|
}
|
|
}
|
|
}
|
|
if s.Validate != nil && !hasCustom {
|
|
t.Errorf("%s: Validate is not published as a custom constraint", name)
|
|
}
|
|
if hasCustom && s.Validate == nil {
|
|
t.Errorf("%s: custom constraint has no Validate implementation", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAllShortcutsAssemble drives every registered shortcut end-to-end through
|
|
// the cobra tree with synthesized inputs and asserts each one is healthy:
|
|
// - it either assembles an MCP call with a real (non-hallucinated) tool name,
|
|
// - or it is rejected by its own validation (proving the plumbing ran),
|
|
// - and it never panics or silently no-ops.
|
|
func TestAllShortcutsAssemble(t *testing.T) {
|
|
silenceProcessOutput(t)
|
|
fake := &fakeCaller{}
|
|
helpers.InitDeps(fake)
|
|
|
|
root := newCoverageRoot()
|
|
|
|
real := realToolSet(t)
|
|
all := shortcut.All()
|
|
if len(all) == 0 {
|
|
t.Fatal("no shortcuts registered")
|
|
}
|
|
|
|
var assembled, validated, failed int
|
|
var validatedNames []string
|
|
for _, s := range all {
|
|
name := s.Service + " " + s.Command
|
|
args := append(synthArgs(s), "--yes")
|
|
|
|
fake.reset()
|
|
err := func() (e error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Errorf("PANIC in %s: %v", name, r)
|
|
e = nil
|
|
}
|
|
}()
|
|
root.SetArgs(args)
|
|
return root.Execute()
|
|
}()
|
|
|
|
switch {
|
|
case fake.called:
|
|
assembled++
|
|
if fake.tool == "" {
|
|
t.Errorf("%s: assembled call with EMPTY tool name", name)
|
|
} else if !real[fake.tool] {
|
|
t.Errorf("%s: tool %q not found in any helper (hallucinated?)", name, fake.tool)
|
|
}
|
|
if fake.product == "" {
|
|
t.Errorf("%s: assembled call with EMPTY product", name)
|
|
}
|
|
case err != nil:
|
|
// Rejected by required/enum/Validate before dispatch — plumbing OK.
|
|
validated++
|
|
validatedNames = append(validatedNames, s.Service+s.Command+"="+err.Error())
|
|
default:
|
|
failed++
|
|
t.Errorf("%s: returned nil error but never assembled an MCP call (dead command)", name)
|
|
}
|
|
}
|
|
|
|
t.Logf("shortcuts=%d assembled(真实MCP)=%d validated(自校验拦截)=%d failed=%d",
|
|
len(all), assembled, validated, failed)
|
|
t.Logf("validated(自校验拦截) 明细: %s", strings.Join(validatedNames, " "))
|
|
if assembled == 0 {
|
|
t.Fatal("no shortcut assembled an MCP call — harness likely broken")
|
|
}
|
|
}
|
|
|
|
func TestAllShortcutsHandleRepresentativeResponseShapes(t *testing.T) {
|
|
silenceProcessOutput(t)
|
|
all := shortcut.All()
|
|
for _, mode := range []int{1, 2, 3} {
|
|
fake := &fakeCaller{payload: mode}
|
|
helpers.InitDeps(fake)
|
|
root := newCoverageRoot()
|
|
called := 0
|
|
for _, s := range all {
|
|
fake.reset()
|
|
root.SetArgs(append(synthArgs(s), "--yes"))
|
|
func() {
|
|
defer func() {
|
|
if recovered := recover(); recovered != nil {
|
|
t.Errorf("payload mode %d panicked in %s %s: %v", mode, s.Service, s.Command, recovered)
|
|
}
|
|
}()
|
|
_ = root.Execute()
|
|
}()
|
|
if fake.called {
|
|
called++
|
|
}
|
|
}
|
|
if called < 350 {
|
|
t.Errorf("payload mode %d reached only %d/%d shortcuts", mode, called, len(all))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageReplaceBatchDryRunDoesNotCallTool(t *testing.T) {
|
|
fake := &fakeCaller{dryRun: true}
|
|
helpers.InitDeps(fake)
|
|
|
|
root := newCoverageRoot()
|
|
root.SetArgs([]string{
|
|
"minutes", "+replace-batch", "--id", "task-1",
|
|
"--pair", "Q2=>第二季度", "--dry-run", "--yes",
|
|
})
|
|
if err := root.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fake.called {
|
|
t.Fatalf("dry-run called real tool %s/%s with %#v", fake.product, fake.tool, fake.args)
|
|
}
|
|
}
|
|
|
|
// TestAllToolLiteralsAreReal statically scans every shortcut package source for
|
|
// CallMCP("tool", ...) literals and asserts each tool exists in the helper
|
|
// ground truth. This covers the ~55 shortcuts whose own validation blocks the
|
|
// runtime assemble path, so no shortcut's tool name goes unverified.
|
|
func TestCrossPlatformCoverageAllToolLiteralsAreReal(t *testing.T) {
|
|
real := realToolSet(t)
|
|
srcs, err := filepath.Glob("../*/*.go")
|
|
if err != nil || len(srcs) == 0 {
|
|
t.Fatalf("cannot locate shortcut sources: %v", err)
|
|
}
|
|
// CallMCP("tool", ...) — 1:1 wrappers; the tool is the 1st arg.
|
|
callRe := regexp.MustCompile(`CallMCP\("([^"]+)"`)
|
|
// CallMCPData("product", "tool", ...) — multi-step (smart) shortcuts; the
|
|
// tool is the 2nd arg.
|
|
dataRe := regexp.MustCompile(`CallMCPData\("[^"]+",\s*"([^"]+)"`)
|
|
var checked, bad int
|
|
check := func(f, tool string) {
|
|
checked++
|
|
if !real[tool] {
|
|
bad++
|
|
t.Errorf("%s: tool %q not found in any helper (hallucinated?)", filepath.Base(f), tool)
|
|
}
|
|
}
|
|
for _, f := range srcs {
|
|
if strings.HasSuffix(f, "_test.go") || strings.Contains(f, "/builtin/") {
|
|
continue
|
|
}
|
|
data, err := os.ReadFile(f)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
src := string(data)
|
|
for _, m := range callRe.FindAllStringSubmatch(src, -1) {
|
|
check(f, m[1])
|
|
}
|
|
for _, m := range dataRe.FindAllStringSubmatch(src, -1) {
|
|
check(f, m[1])
|
|
}
|
|
}
|
|
t.Logf("tool literals checked=%d hallucinated=%d", checked, bad)
|
|
}
|
|
|
|
// TestAllHaveIntent enforces that every shortcut carries a natural-language
|
|
// Intent (a fuller "what/when to use" description for discovery and AI-agent
|
|
// matching), not just the terse one-line Description.
|
|
func TestCrossPlatformCoverageAllHaveIntent(t *testing.T) {
|
|
var missing []string
|
|
for _, s := range shortcut.All() {
|
|
if strings.TrimSpace(s.Intent) == "" {
|
|
missing = append(missing, s.Service+" "+s.Command)
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
t.Errorf("%d shortcut(s) missing Intent (natural-language description):\n %s",
|
|
len(missing), strings.Join(missing, "\n "))
|
|
}
|
|
}
|
|
|
|
// TestNoDuplicateCommands guards against two shortcuts colliding on the same
|
|
// service+command path.
|
|
func TestCrossPlatformCoverageNoDuplicateCommands(t *testing.T) {
|
|
seen := map[string]bool{}
|
|
for _, s := range shortcut.All() {
|
|
key := s.Service + " " + s.Command
|
|
if seen[key] {
|
|
t.Errorf("duplicate shortcut: %s", key)
|
|
}
|
|
seen[key] = true
|
|
if !strings.HasPrefix(s.Command, "+") {
|
|
t.Errorf("%s: command must start with '+'", key)
|
|
}
|
|
}
|
|
}
|