* chore: update coverage badge [skip ci] * chore: update coverage badge [skip ci] * chore: update coverage badge [skip ci] * chore: update coverage badge [skip ci] * chore: update coverage badge [skip ci] * 1.0.19 changelog * stick opt * fix(cli): utf-8 safe sticky suffix guard + changelog (#272) SuffixLooksLikeValue used to read suffix[0] (a single byte) for both the uuid format branch and the fallback "is the first rune a letter?" check. For multi-byte UTF-8 leading runes — common in dws because value text is often Chinese — this picked up only the first byte (0xE0..0xF4 lead), which is not a letter and not a hex digit, so the function silently returned true and let glued tokens like --name<CJK> get split into --name <CJK>... This switches both branches to utf8.DecodeRuneInString and adds a utf8.RuneError guard so invalid UTF-8 input is rejected too. Also locks down the new behaviour in CHANGELOG ## [Unreleased]: - Changed: schema-aware sticky flag splitting - Added: available_flags field on unknown-flag errors Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
506 lines
15 KiB
Go
506 lines
15 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 handlers
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
|
)
|
|
|
|
// TestFullPreParsePipeline exercises the complete PreParse handler
|
|
// chain: AliasHandler → StickyHandler → ParamNameHandler. It
|
|
// simulates a model-generated CLI invocation with multiple errors
|
|
// and verifies the pipeline corrects all of them in one pass.
|
|
func TestFullPreParsePipeline(t *testing.T) {
|
|
engine := pipeline.NewEngine()
|
|
engine.RegisterAll(
|
|
AliasHandler{},
|
|
StickyHandler{},
|
|
ParamNameHandler{},
|
|
)
|
|
|
|
// Numeric / boolean flag typing matters for the sticky guard. The
|
|
// helper below declares known flags as int so digit-led suffixes
|
|
// pass the suffixLooksLikeValue check.
|
|
intSpecs := func(names ...string) []pipeline.FlagInfo {
|
|
out := make([]pipeline.FlagInfo, len(names))
|
|
for i, n := range names {
|
|
out[i] = pipeline.FlagInfo{Name: n, Type: "int"}
|
|
}
|
|
return out
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
flags []pipeline.FlagInfo
|
|
want string
|
|
corrections int
|
|
}{
|
|
{
|
|
name: "camelCase + sticky combined",
|
|
args: []string{"--userId", "123", "--pageSize50"},
|
|
flags: intSpecs("user-id", "page-size"),
|
|
want: "--user-id 123 --page-size 50",
|
|
corrections: 2, // alias(userId) + sticky(pageSize50)
|
|
},
|
|
{
|
|
name: "camelCase + typo combined",
|
|
args: []string{"--userId", "123", "--limt", "10"},
|
|
flags: intSpecs("user-id", "limit"),
|
|
want: "--user-id 123 --limit 10",
|
|
corrections: 2, // alias(userId) + fuzzy(limt)
|
|
},
|
|
{
|
|
name: "triple error: case + sticky + typo",
|
|
args: []string{"--UserName", "alice", "--limit100", "--offse", "0"},
|
|
flags: append(flagSpecs("user-name"), intSpecs("limit", "offset")...),
|
|
want: "--user-name alice --limit 100 --offset 0",
|
|
corrections: 3,
|
|
},
|
|
{
|
|
name: "snake_case + sticky",
|
|
args: []string{"--user_id", "42", "--pageSize20"},
|
|
flags: intSpecs("user-id", "page-size"),
|
|
want: "--user-id 42 --page-size 20",
|
|
corrections: 2,
|
|
},
|
|
{
|
|
name: "all correct — zero corrections",
|
|
args: []string{"--user-id", "123", "--limit", "10"},
|
|
flags: intSpecs("user-id", "limit"),
|
|
want: "--user-id 123 --limit 10",
|
|
corrections: 0,
|
|
},
|
|
{
|
|
name: "UPPER case flags",
|
|
args: []string{"--USER-ID", "999"},
|
|
flags: intSpecs("user-id"),
|
|
want: "--user-id 999",
|
|
corrections: 1,
|
|
},
|
|
{
|
|
name: "= syntax with camelCase",
|
|
args: []string{"--userId=123", "--pageSize=50"},
|
|
flags: intSpecs("user-id", "page-size"),
|
|
want: "--user-id=123 --page-size=50",
|
|
corrections: 2,
|
|
},
|
|
{
|
|
name: "camelCase sticky split with normalisation",
|
|
args: []string{"--limitValue100"},
|
|
flags: intSpecs("limit-value"),
|
|
want: "--limit-value 100",
|
|
corrections: 1, // sticky handles both kebab-normalisation and split
|
|
},
|
|
|
|
// Hardening: a mistyped flag whose name happens to start with
|
|
// a real flag must NOT be split. The pipeline should leave the
|
|
// token untouched so Cobra can raise "unknown flag".
|
|
{
|
|
name: "typo --starttime1 not split (date-time format)",
|
|
args: []string{"--starttime1", "2026-02-07"},
|
|
flags: []pipeline.FlagInfo{
|
|
{Name: "start", Type: "string", Format: "date-time"},
|
|
{Name: "end", Type: "string", Format: "date-time"},
|
|
},
|
|
want: "--starttime1 2026-02-07",
|
|
corrections: 0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ctx := &pipeline.Context{
|
|
Args: append([]string{}, tt.args...),
|
|
FlagSpecs: tt.flags,
|
|
}
|
|
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
|
|
t.Fatalf("RunPhase error: %v", err)
|
|
}
|
|
got := strings.Join(ctx.Args, " ")
|
|
if got != tt.want {
|
|
t.Errorf("Args = %q, want %q", got, tt.want)
|
|
}
|
|
if len(ctx.Corrections) != tt.corrections {
|
|
t.Errorf("Corrections = %d, want %d", len(ctx.Corrections), tt.corrections)
|
|
for _, c := range ctx.Corrections {
|
|
t.Logf(" %s/%s: %q → %q", c.Handler, c.Kind, c.Original, c.Corrected)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFullPostParsePipeline exercises the PostParse handler chain
|
|
// with the ParamValueHandler normalising multiple value types in a
|
|
// single invocation.
|
|
func TestFullPostParsePipeline(t *testing.T) {
|
|
engine := pipeline.NewEngine()
|
|
engine.RegisterAll(ParamValueHandler{})
|
|
|
|
ctx := &pipeline.Context{
|
|
Command: "aitable.list_records",
|
|
Params: map[string]any{
|
|
"verbose": "yes",
|
|
"limit": "1,000",
|
|
"status": "ACTIVE",
|
|
"created_at": "2024/03/29",
|
|
"name": "test",
|
|
},
|
|
Schema: map[string]any{
|
|
"properties": map[string]any{
|
|
"verbose": map[string]any{"type": "boolean"},
|
|
"limit": map[string]any{"type": "integer"},
|
|
"status": map[string]any{"type": "string", "enum": []any{"active", "inactive"}},
|
|
"created_at": map[string]any{"type": "string", "format": "date"},
|
|
"name": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
}
|
|
|
|
if err := engine.RunPhase(pipeline.PostParse, ctx); err != nil {
|
|
t.Fatalf("RunPhase error: %v", err)
|
|
}
|
|
|
|
if got := ctx.Params["verbose"]; got != true {
|
|
t.Errorf("verbose = %v, want true", got)
|
|
}
|
|
if got := ctx.Params["limit"]; got != int64(1000) {
|
|
t.Errorf("limit = %v, want 1000", got)
|
|
}
|
|
if got := ctx.Params["status"]; got != "active" {
|
|
t.Errorf("status = %q, want %q", got, "active")
|
|
}
|
|
if got := ctx.Params["created_at"]; got != "2024-03-29" {
|
|
t.Errorf("created_at = %q, want %q", got, "2024-03-29")
|
|
}
|
|
// "name" is a plain string — should be untouched.
|
|
if got := ctx.Params["name"]; got != "test" {
|
|
t.Errorf("name = %q, want %q", got, "test")
|
|
}
|
|
if len(ctx.Corrections) != 4 {
|
|
t.Errorf("Corrections = %d, want 4", len(ctx.Corrections))
|
|
}
|
|
}
|
|
|
|
// TestFullPipelineEndToEnd exercises all phases in sequence:
|
|
// PreParse → PostParse, simulating a model invocation that has
|
|
// errors at both the argv level and the value level.
|
|
func TestFullPipelineEndToEnd(t *testing.T) {
|
|
engine := pipeline.NewEngine()
|
|
engine.RegisterAll(
|
|
AliasHandler{},
|
|
StickyHandler{},
|
|
ParamNameHandler{},
|
|
ParamValueHandler{},
|
|
)
|
|
|
|
// Phase 1: PreParse — fix raw argv.
|
|
ctx := &pipeline.Context{
|
|
Args: []string{
|
|
"--userId", "u001",
|
|
"--pageSize50",
|
|
"--verbosetrue",
|
|
},
|
|
FlagSpecs: []pipeline.FlagInfo{
|
|
{Name: "user-id", Type: "string"},
|
|
{Name: "page-size", Type: "int"},
|
|
{Name: "verbose", Type: "bool"},
|
|
},
|
|
}
|
|
|
|
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
|
|
t.Fatalf("PreParse error: %v", err)
|
|
}
|
|
|
|
want := "--user-id u001 --page-size 50 --verbose true"
|
|
got := strings.Join(ctx.Args, " ")
|
|
if got != want {
|
|
t.Errorf("after PreParse: Args = %q, want %q", got, want)
|
|
}
|
|
|
|
preParseCorrections := len(ctx.Corrections)
|
|
if preParseCorrections != 3 {
|
|
t.Errorf("PreParse corrections = %d, want 3", preParseCorrections)
|
|
}
|
|
|
|
// Phase 2: PostParse — simulate Cobra having parsed the corrected
|
|
// args into structured params, then normalise values.
|
|
ctx.Command = "chat.send_message"
|
|
ctx.Params = map[string]any{
|
|
"user_id": "u001",
|
|
"page_size": "50",
|
|
"verbose": "true",
|
|
}
|
|
ctx.Schema = map[string]any{
|
|
"properties": map[string]any{
|
|
"user_id": map[string]any{"type": "string"},
|
|
"page_size": map[string]any{"type": "integer"},
|
|
"verbose": map[string]any{"type": "boolean"},
|
|
},
|
|
}
|
|
|
|
if err := engine.RunPhase(pipeline.PostParse, ctx); err != nil {
|
|
t.Fatalf("PostParse error: %v", err)
|
|
}
|
|
|
|
if got := ctx.Params["verbose"]; got != true {
|
|
t.Errorf("verbose = %v (%T), want true (bool)", got, got)
|
|
}
|
|
// page_size "50" has no grouping separators — unchanged.
|
|
if got := ctx.Params["page_size"]; got != "50" {
|
|
t.Errorf("page_size = %v, want %q", got, "50")
|
|
}
|
|
|
|
totalCorrections := len(ctx.Corrections)
|
|
postParseCorrections := totalCorrections - preParseCorrections
|
|
if postParseCorrections != 1 {
|
|
t.Errorf("PostParse corrections = %d, want 1", postParseCorrections)
|
|
}
|
|
}
|
|
|
|
// TestFullFivePhasePipeline exercises all five phases in order:
|
|
// Register → PreParse → PostParse → PreRequest → PostResponse,
|
|
// simulating a complete command lifecycle from registration through
|
|
// response output.
|
|
func TestFullFivePhasePipeline(t *testing.T) {
|
|
engine := pipeline.NewEngine()
|
|
engine.RegisterAll(
|
|
RegisterHandler{},
|
|
AliasHandler{},
|
|
StickyHandler{},
|
|
ParamNameHandler{},
|
|
ParamValueHandler{},
|
|
PreRequestHandler{},
|
|
PostResponseHandler{},
|
|
)
|
|
|
|
// Verify all five phases have handlers.
|
|
for _, phase := range []pipeline.Phase{
|
|
pipeline.Register,
|
|
pipeline.PreParse,
|
|
pipeline.PostParse,
|
|
pipeline.PreRequest,
|
|
pipeline.PostResponse,
|
|
} {
|
|
if !engine.HasHandlers(phase) {
|
|
t.Fatalf("engine missing handlers for phase %v", phase)
|
|
}
|
|
}
|
|
|
|
// Phase 1: Register — command tree being built.
|
|
ctx := &pipeline.Context{
|
|
Command: "aitable",
|
|
}
|
|
if err := engine.RunPhase(pipeline.Register, ctx); err != nil {
|
|
t.Fatalf("Register error: %v", err)
|
|
}
|
|
|
|
// Phase 2: PreParse — fix raw argv.
|
|
ctx.Args = []string{
|
|
"--userId", "u001",
|
|
"--pageSize50",
|
|
"--verbosetrue",
|
|
}
|
|
ctx.FlagSpecs = []pipeline.FlagInfo{
|
|
{Name: "user-id", Type: "string"},
|
|
{Name: "page-size", Type: "int"},
|
|
{Name: "verbose", Type: "bool"},
|
|
}
|
|
|
|
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
|
|
t.Fatalf("PreParse error: %v", err)
|
|
}
|
|
|
|
want := "--user-id u001 --page-size 50 --verbose true"
|
|
got := strings.Join(ctx.Args, " ")
|
|
if got != want {
|
|
t.Errorf("after PreParse: Args = %q, want %q", got, want)
|
|
}
|
|
preParseCorrections := len(ctx.Corrections)
|
|
|
|
// Phase 3: PostParse — simulate Cobra having parsed the corrected
|
|
// args into structured params, then normalise values.
|
|
ctx.Command = "aitable.query_records"
|
|
ctx.Params = map[string]any{
|
|
"user_id": "u001",
|
|
"page_size": "1,000",
|
|
"verbose": "yes",
|
|
}
|
|
ctx.Schema = map[string]any{
|
|
"properties": map[string]any{
|
|
"user_id": map[string]any{"type": "string"},
|
|
"page_size": map[string]any{"type": "integer"},
|
|
"verbose": map[string]any{"type": "boolean"},
|
|
},
|
|
}
|
|
|
|
if err := engine.RunPhase(pipeline.PostParse, ctx); err != nil {
|
|
t.Fatalf("PostParse error: %v", err)
|
|
}
|
|
|
|
if got := ctx.Params["verbose"]; got != true {
|
|
t.Errorf("verbose = %v (%T), want true (bool)", got, got)
|
|
}
|
|
if got := ctx.Params["page_size"]; got != int64(1000) {
|
|
t.Errorf("page_size = %v, want 1000", got)
|
|
}
|
|
postParseCorrections := len(ctx.Corrections) - preParseCorrections
|
|
if postParseCorrections != 2 {
|
|
t.Errorf("PostParse corrections = %d, want 2", postParseCorrections)
|
|
}
|
|
|
|
// Phase 4: PreRequest — inspect final payload before dispatch.
|
|
ctx.Payload = ctx.Params
|
|
if err := engine.RunPhase(pipeline.PreRequest, ctx); err != nil {
|
|
t.Fatalf("PreRequest error: %v", err)
|
|
}
|
|
// Verify payload was not corrupted.
|
|
if ctx.Payload["user_id"] != "u001" {
|
|
t.Error("PreRequest corrupted Payload")
|
|
}
|
|
|
|
// Phase 5: PostResponse — process response before output.
|
|
ctx.Response = map[string]any{
|
|
"records": []any{
|
|
map[string]any{"id": "rec001", "fields": map[string]any{"name": "test"}},
|
|
},
|
|
"total": 1,
|
|
}
|
|
if err := engine.RunPhase(pipeline.PostResponse, ctx); err != nil {
|
|
t.Fatalf("PostResponse error: %v", err)
|
|
}
|
|
// Verify response was not corrupted.
|
|
if ctx.Response["total"] != 1 {
|
|
t.Error("PostResponse corrupted Response")
|
|
}
|
|
}
|
|
|
|
// TestFullFivePhasePipelineWithEngineRun exercises all five phases
|
|
// using Engine.Run (single shot) to verify the ordering is correct
|
|
// end-to-end.
|
|
func TestFullFivePhasePipelineWithEngineRun(t *testing.T) {
|
|
var seq []string
|
|
|
|
engine := pipeline.NewEngine()
|
|
engine.RegisterAll(
|
|
&phaseTracker{name: "reg", phase: pipeline.Register, seq: &seq},
|
|
&phaseTracker{name: "pre-parse", phase: pipeline.PreParse, seq: &seq},
|
|
&phaseTracker{name: "post-parse", phase: pipeline.PostParse, seq: &seq},
|
|
&phaseTracker{name: "pre-req", phase: pipeline.PreRequest, seq: &seq},
|
|
&phaseTracker{name: "post-resp", phase: pipeline.PostResponse, seq: &seq},
|
|
)
|
|
|
|
ctx := &pipeline.Context{Command: "test.tool"}
|
|
if err := engine.Run(ctx); err != nil {
|
|
t.Fatalf("Engine.Run error: %v", err)
|
|
}
|
|
|
|
want := "reg,pre-parse,post-parse,pre-req,post-resp"
|
|
got := strings.Join(seq, ",")
|
|
if got != want {
|
|
t.Errorf("phase execution order = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// TestFivePhasePipelineCorrectHandlerCounts verifies that the
|
|
// production-equivalent engine has the expected handler distribution.
|
|
func TestFivePhasePipelineCorrectHandlerCounts(t *testing.T) {
|
|
engine := pipeline.NewEngine()
|
|
engine.RegisterAll(
|
|
RegisterHandler{},
|
|
AliasHandler{},
|
|
StickyHandler{},
|
|
ParamNameHandler{},
|
|
ParamValueHandler{},
|
|
PreRequestHandler{},
|
|
PostResponseHandler{},
|
|
)
|
|
|
|
tests := []struct {
|
|
phase pipeline.Phase
|
|
want int
|
|
}{
|
|
{pipeline.Register, 1},
|
|
{pipeline.PreParse, 3},
|
|
{pipeline.PostParse, 1},
|
|
{pipeline.PreRequest, 1},
|
|
{pipeline.PostResponse, 1},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := len(engine.Handlers(tt.phase)); got != tt.want {
|
|
t.Errorf("Handlers(%v) = %d, want %d", tt.phase, got, tt.want)
|
|
}
|
|
}
|
|
if got := engine.HandlerCount(); got != 7 {
|
|
t.Errorf("HandlerCount = %d, want 7", got)
|
|
}
|
|
}
|
|
|
|
// phaseTracker is a test helper that records its name when Handle is called.
|
|
type phaseTracker struct {
|
|
name string
|
|
phase pipeline.Phase
|
|
seq *[]string
|
|
}
|
|
|
|
func (h *phaseTracker) Name() string { return h.name }
|
|
func (h *phaseTracker) Phase() pipeline.Phase { return h.phase }
|
|
func (h *phaseTracker) Handle(_ *pipeline.Context) error {
|
|
*h.seq = append(*h.seq, h.name)
|
|
return nil
|
|
}
|
|
|
|
// TestPreParseDoesNotBreakValidArgs verifies that valid, correctly
|
|
// formatted args pass through the pipeline without modification.
|
|
func TestPreParseDoesNotBreakValidArgs(t *testing.T) {
|
|
engine := pipeline.NewEngine()
|
|
engine.RegisterAll(
|
|
AliasHandler{},
|
|
StickyHandler{},
|
|
ParamNameHandler{},
|
|
)
|
|
|
|
original := []string{
|
|
"--user-id", "123",
|
|
"--limit", "10",
|
|
"--offset", "0",
|
|
"--format", "json",
|
|
"--verbose",
|
|
}
|
|
ctx := &pipeline.Context{
|
|
Args: append([]string{}, original...),
|
|
FlagSpecs: flagSpecs("user-id", "limit", "offset", "format", "verbose"),
|
|
}
|
|
|
|
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
|
|
t.Fatalf("RunPhase error: %v", err)
|
|
}
|
|
|
|
got := strings.Join(ctx.Args, " ")
|
|
want := strings.Join(original, " ")
|
|
if got != want {
|
|
t.Errorf("Args changed!\n got: %q\n want: %q", got, want)
|
|
}
|
|
if len(ctx.Corrections) != 0 {
|
|
t.Errorf("unexpected corrections: %d", len(ctx.Corrections))
|
|
for _, c := range ctx.Corrections {
|
|
t.Logf(" %s: %q → %q", c.Handler, c.Original, c.Corrected)
|
|
}
|
|
}
|
|
}
|