439 lines
13 KiB
Go
439 lines
13 KiB
Go
package helpers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/pflag"
|
|
)
|
|
|
|
type productExampleCaller struct {
|
|
calls int
|
|
mode string
|
|
dry bool
|
|
format string
|
|
}
|
|
|
|
func (c *productExampleCaller) CallTool(_ context.Context, _, _ string, _ map[string]any) (*edition.ToolResult, error) {
|
|
c.calls++
|
|
switch c.mode {
|
|
case "transport-error":
|
|
return nil, fmt.Errorf("synthetic tool transport failure")
|
|
case "invalid-json":
|
|
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: "{"}}}, nil
|
|
case "empty-content":
|
|
return &edition.ToolResult{}, nil
|
|
case "non-text":
|
|
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "image"}}}, nil
|
|
}
|
|
record := map[string]any{
|
|
"id": "item-id", "openId": "open-id", "unionId": "union-id", "name": "Example",
|
|
"title": "Example", "text": "content", "content": "content", "status": "SUCCESS",
|
|
"type": "text", "url": "https://example.invalid/item", "downloadUrl": "https://example.invalid/download",
|
|
"createdAt": "2026-01-02T03:04:05Z", "updatedAt": "2026-01-02T03:04:05Z",
|
|
"startTime": "2026-01-02T03:04:05Z", "endTime": "2026-01-02T04:04:05Z",
|
|
"fileName": "fixture.txt", "dentryId": float64(1), "spaceId": float64(2),
|
|
"messageId": "message-id", "docId": "doc-id", "workspaceId": "workspace-id",
|
|
"sheetId": "sheet-id", "tableId": "table-id", "baseId": "base-id",
|
|
"recordId": "record-id", "taskId": "task-id", "jobId": "job-id",
|
|
"eventId": "event-id", "calendarId": "calendar-id", "roomId": "room-id",
|
|
"conversationId": "conversation-id", "openConversationId": "cid-item-id",
|
|
"processInstanceId": "process-id", "reportId": "report-id", "templateId": "template-id",
|
|
"todoId": "todo-id", "mediaId": "media-id", "attachmentId": "attachment-id",
|
|
"userId": "user-id", "email": "user@example.com", "code": float64(0), "errcode": float64(0),
|
|
}
|
|
dataMap := cloneStringAnyMap(record)
|
|
for key, value := range map[string]any{
|
|
"success": true, "status": "SUCCESS", "taskStatus": "SUCCESS",
|
|
"hasMore": false, "nextCursor": "", "cursor": "",
|
|
"items": []any{record}, "records": []any{record}, "data": []any{record}, "list": []any{record},
|
|
"result": record, "downloadUrl": "https://example.invalid/download", "resourceUrl": "https://example.invalid/resource",
|
|
"message": map[string]any{"id": "message-id"},
|
|
"emailAccounts": []any{map[string]any{"email": "user@example.com", "type": "PERSONAL"}},
|
|
} {
|
|
dataMap[key] = value
|
|
}
|
|
resultMap := cloneStringAnyMap(record)
|
|
for key, value := range map[string]any{
|
|
"success": true, "status": "SUCCESS", "taskStatus": "SUCCESS", "hasMore": false,
|
|
"nextCursor": "", "items": []any{record}, "records": []any{record}, "data": []any{record},
|
|
"downloadUrl": "https://example.invalid/download", "resourceUrl": "https://example.invalid/resource",
|
|
"uploadKey": "upload-key", "jobId": "job-id", "taskId": "task-id",
|
|
"message": map[string]any{"id": "message-id"},
|
|
"emailAccounts": []any{map[string]any{"email": "user@example.com", "type": "PERSONAL"}},
|
|
} {
|
|
resultMap[key] = value
|
|
}
|
|
payload := map[string]any{
|
|
"success": true,
|
|
"result": resultMap,
|
|
"data": dataMap,
|
|
"items": []any{record},
|
|
"records": []any{record},
|
|
"hasMore": false,
|
|
"nextCursor": "",
|
|
"status": "SUCCESS",
|
|
"taskStatus": "SUCCESS",
|
|
"downloadUrl": "https://example.invalid/download",
|
|
"resourceUrl": "https://example.invalid/resource",
|
|
"presignedUrl": "https://example.invalid/upload",
|
|
"uploadKey": "upload-key",
|
|
"jobId": "job-id",
|
|
"taskId": "task-id",
|
|
"messageId": "message-id",
|
|
"fileName": "fixture.txt",
|
|
"dentryId": float64(1),
|
|
"spaceId": float64(2),
|
|
"code": float64(0),
|
|
"errcode": float64(0),
|
|
}
|
|
if c.mode == "list-shapes" {
|
|
payload["result"] = []any{record}
|
|
payload["data"] = []any{record}
|
|
}
|
|
if c.mode == "flat-record" {
|
|
payload = record
|
|
}
|
|
if c.mode == "nested-data" {
|
|
payload = map[string]any{"data": map[string]any{"result": resultMap}}
|
|
}
|
|
if c.mode == "empty-result" {
|
|
payload = map[string]any{}
|
|
}
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: string(raw)}}}, nil
|
|
}
|
|
|
|
func (c *productExampleCaller) Format() string {
|
|
if c.format == "" {
|
|
return "json"
|
|
}
|
|
return c.format
|
|
}
|
|
func (c *productExampleCaller) DryRun() bool { return c.dry }
|
|
func (*productExampleCaller) Fields() string { return "" }
|
|
func (*productExampleCaller) JQ() string { return "" }
|
|
|
|
func TestCrossPlatformCoverageProductCommandExamplesAreExecutableContracts(t *testing.T) {
|
|
workingDirectory, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("get working directory: %v", err)
|
|
}
|
|
if err := os.Chdir(t.TempDir()); err != nil {
|
|
t.Fatalf("use isolated working directory: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chdir(workingDirectory) })
|
|
|
|
previousDeps := deps
|
|
previousArgs := os.Args
|
|
previousStdin := os.Stdin
|
|
previousPut := httpPutFile
|
|
previousGet := httpGetFile
|
|
previousWhiteboardSleep := whiteboardSleep
|
|
t.Cleanup(func() {
|
|
deps = previousDeps
|
|
os.Args = previousArgs
|
|
os.Stdin = previousStdin
|
|
httpPutFile = previousPut
|
|
httpGetFile = previousGet
|
|
whiteboardSleep = previousWhiteboardSleep
|
|
})
|
|
|
|
caller := &productExampleCaller{}
|
|
InitDeps(caller)
|
|
deps.Out.w = io.Discard
|
|
deps.Out.errW = io.Discard
|
|
// Product examples execute real RunE paths; whiteboard insert retries must
|
|
// not burn the suite timeout on real sleep (race CI uses a 12m package cap).
|
|
// Sheet export uses helperAfter for progressive polling, so keep that clock
|
|
// deterministic too; context deadlines alone still accumulate heavily under
|
|
// Windows coverage instrumentation.
|
|
whiteboardSleep = func(time.Duration) {}
|
|
testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time {
|
|
ch := make(chan time.Time, 1)
|
|
ch <- time.Now()
|
|
return ch
|
|
})
|
|
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
|
httpGetFile = func(_ context.Context, _ string, _ map[string]string, destPath string) error {
|
|
if destPath == "" {
|
|
return nil
|
|
}
|
|
return os.WriteFile(destPath, []byte("example"), 0o600)
|
|
}
|
|
confirmationInput := t.TempDir() + "/confirmations.txt"
|
|
if err := os.WriteFile(confirmationInput, []byte(strings.Repeat("yes\n", 2048)), 0o600); err != nil {
|
|
t.Fatalf("write confirmation input: %v", err)
|
|
}
|
|
confirmationFile, err := os.Open(confirmationInput)
|
|
if err != nil {
|
|
t.Fatalf("open confirmation input: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = confirmationFile.Close() })
|
|
os.Stdin = confirmationFile
|
|
|
|
commands := NewPublicCommands(&captureRunner{})
|
|
executed := 0
|
|
malformedExecuted := 0
|
|
for _, root := range commands {
|
|
installExampleGlobalFlags(root)
|
|
root.SilenceErrors = true
|
|
root.SilenceUsage = true
|
|
root.SetOut(io.Discard)
|
|
root.SetErr(io.Discard)
|
|
os.Args = []string{"dws", root.Name(), "--yes"}
|
|
|
|
for _, invocation := range commandExampleInvocations(root) {
|
|
executed++
|
|
caller.mode = ""
|
|
resetCommandFlags(root)
|
|
root.SetArgs(invocation)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
err := root.ExecuteContext(ctx)
|
|
cancel()
|
|
if isCommandSyntaxError(err) {
|
|
t.Errorf("dws %s %s: %v", root.Name(), strings.Join(invocation, " "), err)
|
|
}
|
|
|
|
for _, mode := range []string{"list-shapes", "flat-record", "nested-data", "empty-result", "empty-content", "non-text", "transport-error", "invalid-json"} {
|
|
caller.mode = mode
|
|
resetCommandFlags(root)
|
|
root.SetArgs(invocation)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
|
_ = root.ExecuteContext(ctx)
|
|
cancel()
|
|
}
|
|
caller.mode = ""
|
|
|
|
for _, malformed := range malformedExampleInvocations(invocation) {
|
|
malformedExecuted++
|
|
resetCommandFlags(root)
|
|
root.SetArgs(malformed)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
|
_ = root.ExecuteContext(ctx)
|
|
cancel()
|
|
}
|
|
}
|
|
}
|
|
|
|
if executed < 400 {
|
|
t.Fatalf("executed %d product examples, want at least 400", executed)
|
|
}
|
|
if caller.calls < 250 {
|
|
t.Fatalf("product examples made %d tool calls, want at least 250", caller.calls)
|
|
}
|
|
if malformedExecuted < 300 {
|
|
t.Fatalf("executed %d malformed example variants, want at least 300", malformedExecuted)
|
|
}
|
|
}
|
|
|
|
func malformedExampleInvocations(invocation []string) [][]string {
|
|
var variants [][]string
|
|
for index, token := range invocation {
|
|
if !strings.HasPrefix(token, "--") {
|
|
continue
|
|
}
|
|
if equal := strings.IndexByte(token, '='); equal >= 0 {
|
|
variant := append([]string(nil), invocation...)
|
|
variant[index] = token[:equal+1] + malformedFlagValue(token[equal+1:])
|
|
variants = append(variants, variant)
|
|
continue
|
|
}
|
|
if index+1 >= len(invocation) || strings.HasPrefix(invocation[index+1], "-") {
|
|
continue
|
|
}
|
|
variant := append([]string(nil), invocation...)
|
|
variant[index+1] = malformedFlagValue(invocation[index+1])
|
|
variants = append(variants, variant)
|
|
}
|
|
return variants
|
|
}
|
|
|
|
func malformedFlagValue(value string) string {
|
|
for _, r := range value {
|
|
if r < '0' || r > '9' {
|
|
return "invalid"
|
|
}
|
|
}
|
|
return "-1"
|
|
}
|
|
|
|
func commandExampleInvocations(root *cobra.Command) [][]string {
|
|
var invocations [][]string
|
|
var walk func(*cobra.Command)
|
|
walk = func(command *cobra.Command) {
|
|
for _, line := range logicalExampleLines(command.Example) {
|
|
tokens, err := splitExampleCommand(line)
|
|
if err != nil || len(tokens) < 2 || tokens[0] != "dws" || tokens[1] != root.Name() {
|
|
continue
|
|
}
|
|
if unsupportedExampleTokens(tokens) {
|
|
continue
|
|
}
|
|
invocations = append(invocations, tokens[2:])
|
|
}
|
|
for _, child := range command.Commands() {
|
|
walk(child)
|
|
}
|
|
}
|
|
walk(root)
|
|
return invocations
|
|
}
|
|
|
|
func logicalExampleLines(example string) []string {
|
|
physical := strings.Split(example, "\n")
|
|
logical := make([]string, 0, len(physical))
|
|
var current string
|
|
for _, line := range physical {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
if current != "" {
|
|
current += " " + line
|
|
} else {
|
|
current = line
|
|
}
|
|
if strings.HasSuffix(current, "\\") {
|
|
current = strings.TrimSpace(strings.TrimSuffix(current, "\\"))
|
|
continue
|
|
}
|
|
if index := strings.Index(current, "dws "); index >= 0 {
|
|
logical = append(logical, current[index:])
|
|
}
|
|
current = ""
|
|
}
|
|
if current != "" {
|
|
if index := strings.Index(current, "dws "); index >= 0 {
|
|
logical = append(logical, current[index:])
|
|
}
|
|
}
|
|
return logical
|
|
}
|
|
|
|
func splitExampleCommand(line string) ([]string, error) {
|
|
var tokens []string
|
|
var token strings.Builder
|
|
var quote rune
|
|
escaped := false
|
|
tokenStarted := false
|
|
flush := func() {
|
|
if tokenStarted {
|
|
tokens = append(tokens, token.String())
|
|
token.Reset()
|
|
tokenStarted = false
|
|
}
|
|
}
|
|
for _, r := range line {
|
|
if escaped {
|
|
token.WriteRune(r)
|
|
tokenStarted = true
|
|
escaped = false
|
|
continue
|
|
}
|
|
if r == '\\' && quote != '\'' {
|
|
escaped = true
|
|
continue
|
|
}
|
|
if quote != 0 {
|
|
if r == quote {
|
|
quote = 0
|
|
} else {
|
|
token.WriteRune(r)
|
|
tokenStarted = true
|
|
}
|
|
continue
|
|
}
|
|
switch {
|
|
case r == '\'' || r == '"':
|
|
quote = r
|
|
tokenStarted = true
|
|
case r == '#':
|
|
flush()
|
|
return tokens, nil
|
|
case unicode.IsSpace(r):
|
|
flush()
|
|
default:
|
|
token.WriteRune(r)
|
|
tokenStarted = true
|
|
}
|
|
}
|
|
if escaped || quote != 0 {
|
|
return nil, fmt.Errorf("unterminated escape or quote")
|
|
}
|
|
flush()
|
|
return tokens, nil
|
|
}
|
|
|
|
func installExampleGlobalFlags(root *cobra.Command) {
|
|
flags := root.PersistentFlags()
|
|
if flags.Lookup("format") == nil {
|
|
flags.String("format", "json", "test global output format")
|
|
}
|
|
if flags.Lookup("fields") == nil {
|
|
flags.String("fields", "", "test global field projection")
|
|
}
|
|
if flags.Lookup("jq") == nil {
|
|
flags.String("jq", "", "test global jq filter")
|
|
}
|
|
if flags.Lookup("timeout") == nil {
|
|
flags.Int("timeout", 30, "test global timeout")
|
|
}
|
|
if flags.Lookup("dry-run") == nil {
|
|
flags.Bool("dry-run", false, "test global dry-run")
|
|
}
|
|
if flags.Lookup("yes") == nil {
|
|
flags.Bool("yes", false, "test global confirmation bypass")
|
|
}
|
|
}
|
|
|
|
func unsupportedExampleTokens(tokens []string) bool {
|
|
for _, token := range tokens {
|
|
if strings.ContainsAny(token, "|`$") || strings.HasPrefix(token, "[") || strings.HasSuffix(token, "]") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func resetCommandFlags(command *cobra.Command) {
|
|
reset := func(flag *pflag.Flag) {
|
|
_ = flag.Value.Set(flag.DefValue)
|
|
flag.Changed = false
|
|
}
|
|
command.Flags().VisitAll(reset)
|
|
command.PersistentFlags().VisitAll(reset)
|
|
for _, child := range command.Commands() {
|
|
resetCommandFlags(child)
|
|
}
|
|
}
|
|
|
|
func isCommandSyntaxError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
message := strings.ToLower(err.Error())
|
|
for _, fragment := range []string{
|
|
"unknown command", "unknown flag", "flag needs an argument", "requires at least",
|
|
"requires exactly", "accepts 1 arg", "accepts 2 arg", "required flag",
|
|
} {
|
|
if strings.Contains(message, fragment) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|