test: close unified framework contract coverage
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestFrameworkErrorProjectionPreservesRecoveryMetadata(t *testing.T) {
|
||||
next := time.Date(2026, 8, 10, 1, 2, 3, 0, time.FixedZone("test", 8*60*60))
|
||||
retry := int64(4)
|
||||
started := true
|
||||
leaf := &helpers.CLIError{Code: "UPSTREAM_CODE", Suggestion: "retry with id", Operation: "create"}
|
||||
call := &transport.CallError{Stage: transport.CallStage("decode"), HTTPStatus: 503, RPCCode: 91, TraceID: "call-trace", Cause: leaf}
|
||||
typed := &apperrors.Error{
|
||||
Category: apperrors.CategoryAPI, Message: "failed", Reason: "upstream_failed", Hint: "use status",
|
||||
Actions: []string{"dws status"}, Retryable: true, RetryableSet: true, RetryAfterSeconds: &retry,
|
||||
RPCCode: 92, RPCData: json.RawMessage(`{"task":"x"}`), Operation: "publish", ServerKey: "server",
|
||||
Origin: "gateway", FailureStage: "response", ExecutionStarted: &started, NextRetryAt: &next,
|
||||
AvailableFlags: []string{"--id"}, Snapshot: "/tmp/snapshot", Details: map[string]any{"id": "x"},
|
||||
ServerDiag: apperrors.ServerDiagnostics{TraceID: "typed-trace", ServerErrorCode: "SERVER_CODE", TechnicalDetail: "detail", FriendlyHint: "friendly", ActionURL: "https://example.test"},
|
||||
Cause: call,
|
||||
}
|
||||
info := errorInfoFromExecutionError(typed)
|
||||
if info.Type != "api" || info.Subtype != "upstream_failed" || info.HTTPStatus != 503 || info.RPCCode != 92 || info.RequestID != "call-trace" || info.TraceID != "typed-trace" {
|
||||
t.Fatalf("projection=%+v", info)
|
||||
}
|
||||
if info.UpstreamCode != "SERVER_CODE" || info.NextRetryAt == "" || info.Cause == "" || info.RPCData == nil || info.ExecutionStarted == nil || !*info.ExecutionStarted {
|
||||
t.Fatalf("recovery metadata=%+v", info)
|
||||
}
|
||||
|
||||
requestCall := &transport.CallError{Stage: transport.CallStage("request"), HTTPStatus: 429, RequestID: "request-id"}
|
||||
requestInfo := errorInfoFromExecutionError(requestCall)
|
||||
if requestInfo.RequestID != "request-id" || requestInfo.HTTPStatus != 429 {
|
||||
t.Fatalf("request projection=%+v", requestInfo)
|
||||
}
|
||||
partial := errorInfoFromExecutionError(&apperrors.Error{Category: apperrors.CategoryPartial, Message: "partial"})
|
||||
if partial.Type != "internal" {
|
||||
t.Fatalf("partial error type=%s", partial.Type)
|
||||
}
|
||||
for code, want := range map[int]string{1: "api", 2: "auth", 3: "validation", 4: "permission", 6: "discovery", 99: "internal"} {
|
||||
if got := errorTypeForExitCode(code); got != want {
|
||||
t.Fatalf("errorTypeForExitCode(%d)=%q", code, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkExecutePreparseUnifiedErrorAndEmissionFallback(t *testing.T) {
|
||||
for _, failWriter := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "unified", true: "fallback"}[failWriter], func(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "leaf"}
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return errors.New("bad preparse") })
|
||||
var stdout bytes.Buffer
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.SetContext(ctx)
|
||||
leaf := &cobra.Command{Use: "leaf"}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
if failWriter {
|
||||
leaf.SetOut(frameworkFailWriter{})
|
||||
} else {
|
||||
leaf.SetOut(&stdout)
|
||||
}
|
||||
leaf.SetErr(&bytes.Buffer{})
|
||||
root.AddCommand(leaf)
|
||||
return root
|
||||
})
|
||||
if code := Execute(); code != 3 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
if !failWriter && !strings.Contains(stdout.String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("stdout=%q", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkPublicRootRequiresResultFromActiveCommand(t *testing.T) {
|
||||
root := NewRootCommand(context.Background())
|
||||
leaf := &cobra.Command{Use: "active-no-result", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
root.SetArgs([]string{"active-no-result"})
|
||||
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "without a CommandResult") {
|
||||
t.Fatalf("ExecuteC error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkAbortOutputSinkRemoveFailure(t *testing.T) {
|
||||
originalRemove := rootRemoveFile
|
||||
t.Cleanup(func() { rootRemoveFile = originalRemove })
|
||||
file, err := os.CreateTemp(t.TempDir(), "abort-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rootRemoveFile = func(string) error { return errors.New("remove failed") }
|
||||
cmd := &cobra.Command{Use: "abort"}
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{file: file, tempPath: file.Name()}))
|
||||
if err := abortOutputSink(cmd); err == nil || !strings.Contains(err.Error(), "remove temporary") {
|
||||
t.Fatalf("abort error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkOutputSinkHookWrappingAndCleanupEdges(t *testing.T) {
|
||||
installOutputSinkErrorCleanup(nil)
|
||||
plain := &cobra.Command{Use: "plain"}
|
||||
plain.SetContext(context.Background())
|
||||
installOutputSinkErrorCleanup(plain)
|
||||
|
||||
file, err := os.CreateTemp(t.TempDir(), "sink-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := &outputSinkState{file: file, tempPath: file.Name(), target: filepath.Join(t.TempDir(), "out")}
|
||||
cmd := &cobra.Command{Use: "all"}
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, state))
|
||||
var calls int
|
||||
cmd.PreRunE = func(*cobra.Command, []string) error { calls++; return nil }
|
||||
cmd.PreRun = func(*cobra.Command, []string) { calls++ }
|
||||
cmd.RunE = func(*cobra.Command, []string) error { calls++; return nil }
|
||||
cmd.Run = func(*cobra.Command, []string) { calls++ }
|
||||
cmd.PostRunE = func(*cobra.Command, []string) error { calls++; return nil }
|
||||
cmd.PostRun = func(*cobra.Command, []string) { calls++ }
|
||||
installOutputSinkErrorCleanup(cmd)
|
||||
if err := cmd.PreRunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.PreRun(cmd, nil)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.Run(cmd, nil)
|
||||
if err := cmd.PostRunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.PostRun(cmd, nil)
|
||||
if calls != 6 {
|
||||
t.Fatalf("hook calls=%d", calls)
|
||||
}
|
||||
|
||||
file2, err := os.CreateTemp(t.TempDir(), "sink-error-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
errorCmd := &cobra.Command{Use: "error"}
|
||||
errorCmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{file: file2, tempPath: file2.Name(), target: "unused"}))
|
||||
if err := runWithOutputSinkErrorCleanup(errorCmd, func() error { return errors.New("boom") }); err == nil {
|
||||
t.Fatal("run error swallowed")
|
||||
}
|
||||
|
||||
file3, err := os.CreateTemp(t.TempDir(), "sink-panic-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
panicCmd := &cobra.Command{Use: "panic"}
|
||||
panicCmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{file: file3, tempPath: file3.Name(), target: "unused"}))
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("panic swallowed")
|
||||
}
|
||||
}()
|
||||
_ = runWithOutputSinkErrorCleanup(panicCmd, func() error { panic("boom") })
|
||||
}()
|
||||
|
||||
if closeOutputSink(nil) != nil || abortOutputSink(nil) != nil || outputSinkForCommand(nil) != nil {
|
||||
t.Fatal("nil sink guards failed")
|
||||
}
|
||||
finished := &outputSinkState{finished: true, file: file}
|
||||
finishedCmd := &cobra.Command{Use: "finished"}
|
||||
finishedCmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, finished))
|
||||
if closeOutputSink(finishedCmd) != nil || abortOutputSink(finishedCmd) != nil {
|
||||
t.Fatal("finished sink was processed twice")
|
||||
}
|
||||
}
|
||||
|
||||
type frameworkFailWriter struct{}
|
||||
|
||||
func (frameworkFailWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
|
||||
|
||||
func TestFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
|
||||
for _, failWriter := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "emits", true: "fallback"}[failWriter], func(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws"}
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
var stdout bytes.Buffer
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "dws"}
|
||||
cmd.SetContext(ctx)
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
if failWriter {
|
||||
cmd.SetOut(frameworkFailWriter{})
|
||||
} else {
|
||||
cmd.SetOut(&stdout)
|
||||
}
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
return cmd
|
||||
})
|
||||
testseam.Swap(t, &rootExecuteCommand, func(*cobra.Command) (*cobra.Command, error) { panic("before emission") })
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
if !failWriter && !strings.Contains(stdout.String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("stdout=%q", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -610,9 +610,6 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
return cmd.Help()
|
||||
},
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := output.ValidateUnifiedFormat(cmd); err != nil {
|
||||
return apperrors.NewValidation(err.Error(), apperrors.WithReason("unsupported_format"))
|
||||
}
|
||||
// Cobra performs these checks after persistent pre-run hooks. Run
|
||||
// them before opening the transactional sink so validation errors
|
||||
// cannot strand a temporary file during direct ExecuteC calls.
|
||||
|
||||
@@ -16,17 +16,22 @@ var rootEscalateSignal = func(sig os.Signal) {
|
||||
redeliverProcessSignal(sig)
|
||||
}
|
||||
|
||||
var (
|
||||
rootFindProcess = os.FindProcess
|
||||
rootExitProcess = os.Exit
|
||||
)
|
||||
|
||||
// redeliverProcessSignal asks the current process to handle the second signal
|
||||
// with the platform's default semantics. Platforms that cannot deliver the
|
||||
// requested signal through os.Process.Signal fall back to the conventional
|
||||
// CLI exit status instead of leaving the process running after escalation.
|
||||
func redeliverProcessSignal(sig os.Signal) {
|
||||
process, err := os.FindProcess(os.Getpid())
|
||||
process, err := rootFindProcess(os.Getpid())
|
||||
if err == nil {
|
||||
err = process.Signal(sig)
|
||||
}
|
||||
if err != nil {
|
||||
os.Exit(interruptionExitCode(sig))
|
||||
rootExitProcess(interruptionExitCode(sig))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
@@ -30,6 +31,59 @@ func signalSelf(t *testing.T, sig syscall.Signal) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkSignalRedeliveryFallbackAndInterruptionMethods(t *testing.T) {
|
||||
originalFind, originalExit := rootFindProcess, rootExitProcess
|
||||
t.Cleanup(func() { rootFindProcess, rootExitProcess = originalFind, originalExit })
|
||||
rootFindProcess = func(int) (*os.Process, error) { return nil, errors.New("find failed") }
|
||||
exitCode := 0
|
||||
rootExitProcess = func(code int) { exitCode = code }
|
||||
rootEscalateSignal(syscall.SIGTERM)
|
||||
if exitCode != 143 {
|
||||
t.Fatalf("escalation exit=%d", exitCode)
|
||||
}
|
||||
exitCode = 0
|
||||
redeliverProcessSignal(syscall.SIGTERM)
|
||||
if exitCode != 143 {
|
||||
t.Fatalf("fallback exit=%d", exitCode)
|
||||
}
|
||||
rootFindProcess = func(int) (*os.Process, error) { return os.FindProcess(99999999) }
|
||||
exitCode = 0
|
||||
redeliverProcessSignal(syscall.SIGINT)
|
||||
if exitCode != 130 {
|
||||
t.Fatalf("signal fallback exit=%d", exitCode)
|
||||
}
|
||||
interrupted := &processInterruption{signal: syscall.SIGINT}
|
||||
if !errors.Is(interrupted, context.Canceled) || interrupted.ExitCode() != 130 || interrupted.Subtype() != "cancelled_by_user" || !strings.Contains(interrupted.Error(), "interrupt") {
|
||||
t.Fatalf("interruption=%v", interrupted)
|
||||
}
|
||||
terminated := &processInterruption{signal: syscall.SIGTERM}
|
||||
if terminated.ExitCode() != 143 || terminated.Subtype() != "terminated" {
|
||||
t.Fatalf("termination=%v", terminated)
|
||||
}
|
||||
state := &processSignalState{}
|
||||
if !state.record(syscall.SIGINT, nil) || state.record(syscall.SIGTERM, nil) {
|
||||
t.Fatal("signal state did not reject a second interruption")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkManageProcessSignalsNilAndEscalation(t *testing.T) {
|
||||
signals := make(chan os.Signal, 3)
|
||||
stopped, escalated := false, make(chan os.Signal, 1)
|
||||
ctx, _, stop := manageProcessSignals(context.Background(), nil, signals, func() { stopped = true }, func(sig os.Signal) { escalated <- sig })
|
||||
signals <- nil
|
||||
signals <- syscall.SIGINT
|
||||
<-ctx.Done()
|
||||
signals <- syscall.SIGTERM
|
||||
if got := <-escalated; got != syscall.SIGTERM {
|
||||
t.Fatalf("escalated=%v", got)
|
||||
}
|
||||
stop()
|
||||
stop()
|
||||
if !stopped {
|
||||
t.Fatal("signal notification was not stopped")
|
||||
}
|
||||
}
|
||||
|
||||
func installSignalExecuteSeams(t *testing.T, unified bool, stdout, stderr io.Writer) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
|
||||
@@ -206,10 +206,7 @@ func canonicalJSONObject(raw json.RawMessage) (json.RawMessage, error) {
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return nil, fmt.Errorf("must be one JSON object")
|
||||
}
|
||||
canonical, err := json.Marshal(object)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot canonicalize JSON object: %w", err)
|
||||
}
|
||||
canonical, _ := json.Marshal(object) // decoded RawMessages are always marshalable
|
||||
return json.RawMessage(canonical), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,53 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFrameworkResultSpecValidationEdges(t *testing.T) {
|
||||
if got, err := NormalizeResultSpec(nil, ""); err != nil || got != nil {
|
||||
t.Fatalf("NormalizeResultSpec(nil)=(%v,%v)", got, err)
|
||||
}
|
||||
base := func() *ResultSpec {
|
||||
return &ResultSpec{Outcomes: []ResultOutcome{ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object"}`)}
|
||||
}
|
||||
if got, err := NormalizeResultSpec(base(), ""); err != nil || got == nil || got.SensitivePaths != nil {
|
||||
t.Fatalf("valid default spec=(%#v,%v)", got, err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
edit func(*ResultSpec)
|
||||
}{
|
||||
{"ndjson invalid path", func(s *ResultSpec) {
|
||||
s.NDJSON = &ResultNDJSONSpec{RecordPath: ".items", RecordSchema: json.RawMessage(`{"type":"object"}`)}
|
||||
}},
|
||||
{"ndjson invalid schema", func(s *ResultSpec) {
|
||||
s.NDJSON = &ResultNDJSONSpec{RecordPath: "items", RecordSchema: json.RawMessage(`[]`)}
|
||||
}},
|
||||
{"pagination invalid cursor", func(s *ResultSpec) { s.Pagination = &ResultPaginationSpec{CursorPath: ".next", ExhaustionPath: "done"} }},
|
||||
{"pagination invalid exhausted", func(s *ResultSpec) {
|
||||
s.Pagination = &ResultPaginationSpec{CursorPath: "next", ExhaustionPath: "done..bad"}
|
||||
}},
|
||||
{"sensitive invalid", func(s *ResultSpec) { s.SensitivePaths = []string{"bad..path"} }},
|
||||
{"empty schema", func(s *ResultSpec) { s.DataSchema = nil }},
|
||||
{"multiple schema", func(s *ResultSpec) { s.DataSchema = json.RawMessage(`{} {}`) }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
spec := base()
|
||||
tc.edit(spec)
|
||||
if _, err := NormalizeResultSpec(spec, "sample"); err == nil {
|
||||
t.Fatalf("invalid spec accepted: %#v", spec)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := validateResultPath("a.$"); err == nil {
|
||||
t.Fatal("unsafe segment accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDryRunSpecValidate(t *testing.T) {
|
||||
for _, kind := range []string{DryRunPreviewInvocation, DryRunPreviewRequest, DryRunPreviewPlan, DryRunPreviewDiff} {
|
||||
if err := (DryRunSpec{PreviewKind: kind}).Validate("sample.run"); err != nil {
|
||||
|
||||
@@ -42,6 +42,10 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
|
||||
Description: "Create Desc",
|
||||
Positionals: []contract.RuntimeSchemaPositional{{Name: "id", Required: true, Index: 0}},
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "invocation", RemoteReads: true},
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
||||
DataSchema: []byte(`{"type":"object"}`),
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
@@ -79,6 +83,9 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
|
||||
if final.DryRun == nil || final.DryRun.PreviewKind != "invocation" || !final.DryRun.RemoteReads {
|
||||
t.Fatalf("dry_run = %#v", final.DryRun)
|
||||
}
|
||||
if final.Result == nil || len(final.Result.Outcomes) != 1 {
|
||||
t.Fatalf("result = %#v", final.Result)
|
||||
}
|
||||
if final.Interface == nil || final.Interface.Mode != "mcp" || final.Interface.Ref == nil || final.Interface.Ref.RPCName != "create_thing" {
|
||||
t.Fatalf("interface = %#v", final.Interface)
|
||||
}
|
||||
@@ -110,6 +117,29 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkContractDeclResultMarksNonEmptyAndRejectsInvalidSchema(t *testing.T) {
|
||||
if (ContractDecl{Result: &contract.ResultSpec{}}).Empty() {
|
||||
t.Fatal("Result declaration was treated as empty")
|
||||
}
|
||||
defer func() {
|
||||
if recovered := recover(); recovered == nil || !strings.Contains(recovered.(string), "invalid Contract.Result") {
|
||||
t.Fatalf("panic=%v", recovered)
|
||||
}
|
||||
}()
|
||||
New(Spec{
|
||||
Use: "bad-result",
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: ContractDecl{
|
||||
Title: "Bad", Description: "bad result",
|
||||
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}},
|
||||
Interface: &contract.InterfaceSpec{Mode: "local", Availability: "available"},
|
||||
Selection: contract.SelectionSpec{AgentSummary: "bad", UseWhen: []string{"bad"}, AvoidWhen: []string{"good"}, Examples: []string{"dws bad-result"}},
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "sample", Name: "bad", CanonicalPath: "sample.bad", CLIPath: "bad-result", PrimaryCLIPath: "bad-result"},
|
||||
},
|
||||
Invoke: func(*Ctx, map[string]any) error { return nil },
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewCommandFallsBackToDeclaredDescriptionWithoutLong(t *testing.T) {
|
||||
// Long wins when authored; without one the mandatory declaration supplies it.
|
||||
cmd := New(Spec{
|
||||
|
||||
@@ -188,3 +188,63 @@ func TestResolveRuntimeSafetyUsesCanonicalOrCLIIdentityAndRejectsUnavailable(t *
|
||||
t.Fatalf("legacy lookup = declared %v ok %v, want false false", declared, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func boolPointer(value bool) *bool { return &value }
|
||||
func intPointer(value int) *int { return &value }
|
||||
|
||||
func TestFrameworkContractFinalDeepCopyAndSafetyConflicts(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "all"}
|
||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(cmd) })
|
||||
payload := contract.ContractFinalPayload{
|
||||
Positionals: []contract.RuntimeSchemaPositional{{Name: "id"}},
|
||||
Parameters: []contract.ParamDecl{{Name: "mode", Enum: []string{"a"}, Required: boolPointer(true)}},
|
||||
Safety: &contract.SafetySpec{Effect: " read ", EffectSource: " source ", Risk: " low ", Confirmation: " not_required ", Idempotency: " idempotent "},
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan"},
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
||||
DataSchema: []byte(`{"type":"object"}`), SensitivePaths: []string{"token"},
|
||||
NDJSON: &contract.ResultNDJSONSpec{RecordPath: "items", RecordSchema: []byte(`{"type":"object"}`)},
|
||||
Pagination: &contract.ResultPaginationSpec{CursorPath: "next", ExhaustionPath: "done"},
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{Ref: &contract.InterfaceRefSpec{}},
|
||||
Selection: &contract.SelectionSpec{
|
||||
UseWhen: []string{"use"}, AvoidWhen: []string{"avoid"}, Prerequisites: []string{"pre"}, Tips: []string{"tip"},
|
||||
WorkflowRefs: []string{"flow"}, Examples: []string{"example"}, SourceRefs: []string{"source"},
|
||||
ExampleDispositions: []contract.ExampleDisposition{{Index: intPointer(1)}}, Reviewed: boolPointer(true),
|
||||
},
|
||||
Identity: &contract.ToolIdentitySpec{CanonicalPath: "sample.all", Aliases: []string{"alias"}},
|
||||
}
|
||||
RegisterRuntimeContractFinal(cmd, payload)
|
||||
got, ok := RuntimeContractFinal(cmd)
|
||||
if !ok || got.Result == payload.Result || got.Result.NDJSON == payload.Result.NDJSON || got.Result.Pagination == payload.Result.Pagination || got.Interface == payload.Interface || got.Selection == payload.Selection || got.Identity == payload.Identity {
|
||||
t.Fatalf("payload not deeply cloned: %#v", got)
|
||||
}
|
||||
payload.Parameters[0].Enum[0] = "changed"
|
||||
*payload.Parameters[0].Required = false
|
||||
*payload.Selection.ExampleDispositions[0].Index = 9
|
||||
*payload.Selection.Reviewed = false
|
||||
again, _ := RuntimeContractFinal(cmd)
|
||||
if again.Parameters[0].Enum[0] != "a" || !*again.Parameters[0].Required || *again.Selection.ExampleDispositions[0].Index != 1 || !*again.Selection.Reviewed {
|
||||
t.Fatalf("stored payload aliased input: %#v", again)
|
||||
}
|
||||
|
||||
matching := &cobra.Command{Use: "matching"}
|
||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(matching) })
|
||||
RegisterRuntimeContractFinal(matching, contract.ContractFinalPayload{Identity: &contract.ToolIdentitySpec{Path: "sample.all", CLIPath: "sample all"}, Safety: &contract.SafetySpec{Effect: "read", EffectSource: "source", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}})
|
||||
if _, declared, valid := ResolveRuntimeSafety("sample.all", ""); !declared || !valid {
|
||||
t.Fatalf("equivalent duplicate=(declared=%v valid=%v)", declared, valid)
|
||||
}
|
||||
|
||||
conflict := &cobra.Command{Use: "conflict"}
|
||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(conflict) })
|
||||
RegisterRuntimeContractFinal(conflict, contract.ContractFinalPayload{Identity: &contract.ToolIdentitySpec{CanonicalPath: "sample.all"}, Safety: &contract.SafetySpec{Effect: "write"}})
|
||||
if _, declared, valid := ResolveRuntimeSafety("sample.all", ""); !declared || valid {
|
||||
t.Fatalf("conflict=(declared=%v valid=%v)", declared, valid)
|
||||
}
|
||||
if runtimeIdentityMatches(contract.ToolIdentitySpec{}, "", "") {
|
||||
t.Fatal("empty identity matched")
|
||||
}
|
||||
if got := cloneSlice[string](nil); got != nil {
|
||||
t.Fatalf("cloneSlice(nil)=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package corecmd
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -49,3 +50,27 @@ func TestResultInvokeCarriesOneFrameworkResult(t *testing.T) {
|
||||
t.Fatalf("rollout=%s", output.CommandRollout(cmd))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkResultInvokeErrorLegacyAndStoreEdges(t *testing.T) {
|
||||
wantErr := errors.New("invoke failed")
|
||||
cases := []struct {
|
||||
name string
|
||||
rollout output.RolloutState
|
||||
invoke func(*Ctx, map[string]any) (output.CommandResult, error)
|
||||
want string
|
||||
}{
|
||||
{"invoke error", output.RolloutUnifiedActive, func(*Ctx, map[string]any) (output.CommandResult, error) { return nil, wantErr }, "invoke failed"},
|
||||
{"legacy guard", output.RolloutLegacyOnly, func(*Ctx, map[string]any) (output.CommandResult, error) { return output.Success(nil), nil }, "without an active unified-result rollout"},
|
||||
{"missing store", output.RolloutUnifiedActive, func(*Ctx, map[string]any) (output.CommandResult, error) { return output.Success(nil), nil }, "no result store"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := New(Spec{Use: "result", OutputRollout: tc.rollout, Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}, ResultInvoke: tc.invoke})
|
||||
cmd.SetArgs(nil)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("Execute error=%v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,26 +335,10 @@ func startDaemon(cmd *cobra.Command, dirKey, clientID, unifiedAppID, channel, no
|
||||
// Release the child so the parent can exit without leaving a zombie.
|
||||
pid := child.Process.Pid
|
||||
_ = child.Process.Release()
|
||||
if !output.UsesUnifiedResult(cmd) {
|
||||
writeConnectDaemonStarted(cmd.OutOrStdout(), pid, logPath, clientID, dirKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 统一输出 dev 域试点(队列 B110):人读提示行走 stderr,stdout 由
|
||||
// 结果信封承载(pid/日志路径等机器事实进 data,ok:true + outcome:success)。
|
||||
writeConnectDaemonStarted(cmd.ErrOrStderr(), pid, logPath, clientID, dirKey)
|
||||
env := &output.Envelope{
|
||||
OK: true,
|
||||
Outcome: output.OutcomeSuccess,
|
||||
Data: &connectDaemonStartedResult{
|
||||
Status: "started",
|
||||
Pid: pid,
|
||||
LogPath: logPath,
|
||||
DirKey: dirKey,
|
||||
ClientID: clientID,
|
||||
},
|
||||
}
|
||||
return writeDevRolloutResult(cmd, output.Success(env.Data), env, output.FormatJSON)
|
||||
// The foreground/daemon root remains one streaming legacy command in this
|
||||
// release. It cannot adopt a terminal result until the modes are split.
|
||||
writeConnectDaemonStarted(cmd.OutOrStdout(), pid, logPath, clientID, dirKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// connectDaemonStartedResult 是 `dev connect --daemon` 父进程的结果 DTO
|
||||
|
||||
@@ -960,3 +960,50 @@ func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
|
||||
t.Fatal("failing restart subprocess succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkConnectControlDryRunPlansAndLegacyListRollback(t *testing.T) {
|
||||
preserveDaemonHooks(t)
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
|
||||
stop := prepareUnifiedTestCommand(newDevAppRobotConnectStopCommand())
|
||||
stop.SetArgs([]string{"--unified-app-id", "app", "--dry-run"})
|
||||
var stopOut bytes.Buffer
|
||||
stop.SetOut(&stopOut)
|
||||
if err := stop.Execute(); err != nil || !strings.Contains(stopOut.String(), `"preview_kind": "plan"`) {
|
||||
t.Fatalf("stop preview=%q err=%v", stopOut.String(), err)
|
||||
}
|
||||
|
||||
restart := prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
||||
restart.SetArgs([]string{"--unified-app-id", "app", "--dry-run"})
|
||||
var restartOut bytes.Buffer
|
||||
restart.SetOut(&restartOut)
|
||||
if err := restart.Execute(); err != nil || !strings.Contains(restartOut.String(), `"preview_kind": "plan"`) {
|
||||
t.Fatalf("restart preview=%q err=%v", restartOut.String(), err)
|
||||
}
|
||||
|
||||
dir, err := connectDaemonDir("saved")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeDaemonState(dir, daemonState{DirKey: "saved", UnifiedAppID: "saved-app", Channel: "custom"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restart = prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
||||
restart.SetArgs([]string{"--robot-client-id", "saved", "--dry-run"})
|
||||
restartOut.Reset()
|
||||
restart.SetOut(&restartOut)
|
||||
if err := restart.Execute(); err != nil || !strings.Contains(restartOut.String(), "saved-app") {
|
||||
t.Fatalf("saved restart preview=%q err=%v", restartOut.String(), err)
|
||||
}
|
||||
|
||||
list := prepareUnifiedTestCommand(newDevAppRobotConnectListCommand(connectResponseRunner{response: map[string]any{"items": []any{}, "hasMore": false}}))
|
||||
output.SetCommandRollout(list, output.RolloutLegacyOnly)
|
||||
for _, format := range []string{"json", "table", "pretty"} {
|
||||
var out bytes.Buffer
|
||||
list.SetOut(&out)
|
||||
list.SetArgs([]string{"--format", format})
|
||||
if err := list.Execute(); err != nil {
|
||||
t.Fatalf("legacy list %s: %v", format, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestDevAppSharedResultMapperClassifiesServiceOutcomes(t *testing.T) {
|
||||
@@ -126,3 +130,117 @@ func TestDevAppSharedResultMapperClassifiesServiceOutcomes(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFrameworkDevAppMapperBoundaryMatrix(t *testing.T) {
|
||||
for name, payload := range map[string]any{
|
||||
"scalar": "value",
|
||||
"bad cursor": map[string]any{"nextCursor": 1},
|
||||
"cursor only": map[string]any{"nextCursor": "next"},
|
||||
"empty cursor": map[string]any{"nextCursor": ""},
|
||||
"exhausted": map[string]any{"hasMore": false},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) { _, _ = devAppPaginationMeta(payload) })
|
||||
}
|
||||
|
||||
if devAppMultiProfileResult(map[string]any{}) != nil || devAppMultiProfileResult(map[string]any{"multiProfile": true}) != nil {
|
||||
t.Fatal("non-profile payload classified")
|
||||
}
|
||||
allSuccess := devAppMultiProfileResult(map[string]any{"multiProfile": true, "profiles": []any{map[string]any{"ok": true}}})
|
||||
if allSuccess != nil {
|
||||
t.Fatalf("all-success multi profile=%v", allSuccess)
|
||||
}
|
||||
allFailed := devAppMultiProfileResult(map[string]any{"multiProfile": true, "profiles": []any{
|
||||
map[string]any{"ok": false, "error": map[string]any{
|
||||
"type": "authorization", "message": "denied", "retryable": true, "execution_started": false,
|
||||
"actions": []any{"retry", 1}, "details": map[string]any{"scope": "x"}, "requestId": "req", "traceId": "trace",
|
||||
}},
|
||||
"malformed",
|
||||
}})
|
||||
allFailedEnv, err := output.EnvelopeFromResult(allFailed)
|
||||
if err != nil || allFailedEnv.Outcome != output.OutcomeFailure || allFailedEnv.Error == nil || allFailedEnv.Error.Details == nil {
|
||||
t.Fatalf("all-failed=%+v err=%v", allFailedEnv, err)
|
||||
}
|
||||
mixed := devAppMultiProfileResult(map[string]any{"multiProfile": true, "profiles": []any{
|
||||
map[string]any{"selector": "ok", "ok": true},
|
||||
map[string]any{"selector": "bad", "ok": false, "error": map[string]any{"category": "forbidden", "actions": []string{"fix"}, "code": "E"}},
|
||||
123,
|
||||
}})
|
||||
if env, err := output.EnvelopeFromResult(mixed); err != nil || env.Outcome != output.OutcomePartialFailure {
|
||||
t.Fatalf("mixed=%+v err=%v", env, err)
|
||||
}
|
||||
duplicate := devAppMultiProfileResult(map[string]any{"multiProfile": true, "profiles": []any{
|
||||
map[string]any{"selector": "same", "ok": true},
|
||||
map[string]any{"selector": "same", "ok": false},
|
||||
}})
|
||||
if env, err := output.EnvelopeFromResult(duplicate); err != nil || env.Outcome != output.OutcomeFailure || env.Error.Type != "internal" {
|
||||
t.Fatalf("duplicate=%+v err=%v", env, err)
|
||||
}
|
||||
|
||||
for _, payload := range []map[string]any{
|
||||
{"success": false, "message": "no", "errorCode": "E"},
|
||||
{"status": "FAILED", "code": 1},
|
||||
{"status": "OK"},
|
||||
} {
|
||||
_ = devAppFailureResult(payload)
|
||||
}
|
||||
for _, raw := range []string{"validation", "forbidden", "timeout"} {
|
||||
_ = devAppWireErrorType(raw)
|
||||
}
|
||||
|
||||
for _, content := range []map[string]any{
|
||||
{},
|
||||
{"mustAskUser": true},
|
||||
{"status": "WAITING", "taskId": "task"},
|
||||
{"status": "WAITING", "requestId": "req"},
|
||||
{"status": "WAITING", "taskId": "task", "nextSteps": []map[string]any{{"command": "dws next"}}},
|
||||
{"status": "WAITING", "requestId": "req", "nextSteps": []any{map[string]any{"dryRunCommand": "dws preview"}, "bad"}},
|
||||
{"approvalSubmitted": true, "unifiedAppId": "app", "versionId": "v"},
|
||||
} {
|
||||
result := devAppPendingResult(content)
|
||||
if result != nil {
|
||||
_, _ = output.EnvelopeFromResult(result)
|
||||
}
|
||||
}
|
||||
if got := devAppRecoveryCommand(map[string]any{"taskId": "t"}); !bytes.Contains([]byte(got), []byte("robot result")) {
|
||||
t.Fatalf("task recovery=%q", got)
|
||||
}
|
||||
if got := devAppRecoveryCommand(map[string]any{}); got != "" {
|
||||
t.Fatalf("empty recovery=%q", got)
|
||||
}
|
||||
|
||||
if got := devAppEnvelopeData(executor.Result{}); got == nil {
|
||||
t.Fatal("unimplemented result should be preserved")
|
||||
}
|
||||
for _, result := range []executor.Result{
|
||||
{Invocation: executor.Invocation{Implemented: true, Kind: "helper_invocation", DryRun: true}, Response: map[string]any{"content": map[string]any{"preview": true}}},
|
||||
{Invocation: executor.Invocation{Implemented: true, Kind: "helper_invocation"}, Response: map[string]any{"content": map[string]any{"items": []any{}, "hasMore": false}}},
|
||||
} {
|
||||
cmd := &cobra.Command{Use: "legacy-envelope"}
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
if err := writeDevAppEnvelope(cmd, result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
legacy := &cobra.Command{Use: "legacy"}
|
||||
legacy.SetContext(context.Background())
|
||||
legacy.SetOut(&bytes.Buffer{})
|
||||
if err := writeDevRolloutResult(legacy, output.Success(nil), output.NewSuccessEnvelope(nil), output.FormatJSON); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dual := &cobra.Command{Use: "dual"}
|
||||
dual.SetContext(context.Background())
|
||||
dual.SetOut(&bytes.Buffer{})
|
||||
output.SetCommandRollout(dual, output.RolloutDualValidate)
|
||||
if err := writeDevRolloutResult(dual, output.Pending(nil, nil), output.NewSuccessEnvelope(nil), output.FormatJSON); err == nil {
|
||||
t.Fatal("dual validate accepted malformed shadow result")
|
||||
}
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
active := &cobra.Command{Use: "active"}
|
||||
active.SetContext(ctx)
|
||||
output.SetCommandRollout(active, output.RolloutUnifiedActive)
|
||||
if err := writeDevRolloutResult(active, output.Success(nil), nil, output.FormatJSON); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,9 +106,7 @@ func emitResult(cmd *cobra.Command, result CommandResult) (int, bool, error) {
|
||||
message = env.Error.Message
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if _, err := fmt.Fprintln(&buf, "Error: "+message); err != nil {
|
||||
return exitCodeInternal, false, err
|
||||
}
|
||||
_, _ = fmt.Fprintln(&buf, "Error: "+message)
|
||||
n, err := writeAllCount(stderr, buf.Bytes())
|
||||
if err != nil {
|
||||
return exitCodeInternal, n > 0, err
|
||||
@@ -230,9 +228,7 @@ func renderEnvelopeInto(buf *bytes.Buffer, errW io.Writer, env *Envelope, format
|
||||
return err
|
||||
}
|
||||
if summary := renderMetaSummary(env.Meta); summary != "" {
|
||||
if _, err := fmt.Fprintf(buf, "\n%s\n", summary); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintf(buf, "\n%s\n", summary)
|
||||
}
|
||||
return nil
|
||||
case FormatCSV, FormatNDJSON:
|
||||
|
||||
@@ -160,9 +160,6 @@ func jsonShorthandActive(cmd *cobra.Command) bool {
|
||||
return false
|
||||
}
|
||||
for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags(), cmd.PersistentFlags()} {
|
||||
if flags == nil {
|
||||
continue
|
||||
}
|
||||
flag := flags.Lookup("json")
|
||||
if flag == nil || !flag.Changed {
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type forgedResult struct {
|
||||
env Envelope
|
||||
exit int
|
||||
}
|
||||
|
||||
func (r forgedResult) Outcome() Outcome { return r.env.Outcome }
|
||||
func (r forgedResult) ExitCode() int { return r.exit }
|
||||
func (r forgedResult) envelope() *Envelope { copy := r.env; return © }
|
||||
|
||||
type cloneNode struct {
|
||||
Name string
|
||||
Next *cloneNode
|
||||
Values []string
|
||||
Hidden *string
|
||||
}
|
||||
|
||||
func TestFrameworkResultCloneAndValidationEdges(t *testing.T) {
|
||||
if cloneReflectValue(reflect.Value{}, nil).IsValid() {
|
||||
t.Fatal("invalid reflect value became valid")
|
||||
}
|
||||
var nilAny any
|
||||
if got := cloneResultData(nilAny); got != nil {
|
||||
t.Fatalf("cloneResultData(nil)=%v", got)
|
||||
}
|
||||
|
||||
node := &cloneNode{Name: "before", Values: []string{"one"}}
|
||||
node.Next = node
|
||||
payload := map[string]any{
|
||||
"node": node,
|
||||
"nil_ptr": (*cloneNode)(nil),
|
||||
"nil_map": map[string]string(nil),
|
||||
"nil_slice": []string(nil),
|
||||
"array": [2]string{"a", "b"},
|
||||
"interface": any([]string{"x"}),
|
||||
}
|
||||
cloned := cloneResultData(payload).(map[string]any)
|
||||
gotNode := cloned["node"].(*cloneNode)
|
||||
if gotNode == node || gotNode.Next != gotNode || gotNode.Values[0] != "one" {
|
||||
t.Fatalf("cycle-safe clone=%#v", gotNode)
|
||||
}
|
||||
node.Name, node.Values[0] = "after", "two"
|
||||
if gotNode.Name != "before" || gotNode.Values[0] != "one" {
|
||||
t.Fatalf("clone was mutated: %#v", gotNode)
|
||||
}
|
||||
|
||||
started := true
|
||||
retry := int64(3)
|
||||
info := &ErrorInfo{
|
||||
Type: "api", UpstreamCode: map[string]any{"token": "kept"},
|
||||
Params: []string{"p"}, Actions: []string{"a"}, AvailableFlags: []string{"f"},
|
||||
ExecutionStarted: &started, RetryAfterSeconds: &retry,
|
||||
Details: map[string]any{"nested": []any{"x"}}, RPCData: []any{"rpc"},
|
||||
}
|
||||
copyInfo := cloneErrorInfo(info)
|
||||
if copyInfo == info || copyInfo.ExecutionStarted == info.ExecutionStarted || copyInfo.RetryAfterSeconds == info.RetryAfterSeconds {
|
||||
t.Fatal("ErrorInfo pointer fields were not detached")
|
||||
}
|
||||
if cloneErrorInfo(nil) != nil {
|
||||
t.Fatal("cloneErrorInfo(nil) must be nil")
|
||||
}
|
||||
cyclicMap := map[string]any{}
|
||||
cyclicMap["self"] = cyclicMap
|
||||
clonedMap := cloneResultData(cyclicMap).(map[string]any)
|
||||
if reflect.ValueOf(clonedMap).Pointer() == reflect.ValueOf(cyclicMap).Pointer() {
|
||||
t.Fatal("cyclic map was not cloned")
|
||||
}
|
||||
cyclicSlice := make([]any, 1)
|
||||
cyclicSlice[0] = cyclicSlice
|
||||
clonedSlice := cloneResultData(cyclicSlice).([]any)
|
||||
if reflect.ValueOf(clonedSlice).Pointer() == reflect.ValueOf(cyclicSlice).Pointer() {
|
||||
t.Fatal("cyclic slice was not cloned")
|
||||
}
|
||||
|
||||
validFailure := Failure(&ErrorInfo{Type: "api", Message: "failed"})
|
||||
if err := ValidateResult(validFailure); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
validPending := Pending(nil, &OperationInfo{ID: "task", State: "processing", NextCommand: "dws task get"})
|
||||
if err := ValidateResult(validPending); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decorated := Success(nil, WithIdentity("user"), WithDryRun(), ResultOption{})
|
||||
if decorated.Outcome() != OutcomeSuccess {
|
||||
t.Fatalf("Outcome=%s", decorated.Outcome())
|
||||
}
|
||||
decoratedEnv, err := EnvelopeFromResult(decorated)
|
||||
if err != nil || decoratedEnv.Identity != "user" || !decoratedEnv.DryRun {
|
||||
t.Fatalf("decorated=%+v err=%v", decoratedEnv, err)
|
||||
}
|
||||
|
||||
badPending := []CommandResult{
|
||||
Pending(nil, nil),
|
||||
Pending(nil, &OperationInfo{State: "processing", NextCommand: "next"}),
|
||||
Pending(nil, &OperationInfo{ID: "id", NextCommand: "next"}),
|
||||
Pending(nil, &OperationInfo{ID: "id", State: "processing"}),
|
||||
}
|
||||
for _, result := range badPending {
|
||||
if ValidateResult(result) == nil {
|
||||
t.Fatalf("malformed pending accepted: %#v", result)
|
||||
}
|
||||
}
|
||||
if ValidateResult(Partial(nil)) == nil {
|
||||
t.Fatal("nil partial accepted")
|
||||
}
|
||||
if err := StoreResult(context.Background(), Pending(nil, nil)); err == nil {
|
||||
t.Fatal("StoreResult accepted malformed result")
|
||||
}
|
||||
if ValidateResult(forgedResult{env: Envelope{OK: true, Outcome: OutcomeSuccess}, exit: 9}) == nil {
|
||||
t.Fatal("forged exit code accepted")
|
||||
}
|
||||
badOverride := &commandResult{env: Envelope{OK: false, Outcome: OutcomeFailure, Error: &ErrorInfo{Type: "api"}}, exitCodeOverride: true}
|
||||
if ValidateResult(badOverride) == nil {
|
||||
t.Fatal("non-positive override accepted")
|
||||
}
|
||||
badWire := &commandResult{env: Envelope{OK: false, Outcome: OutcomeFailure, Error: &ErrorInfo{Type: "api", ExitCode: 5}}, exitCode: 1}
|
||||
if ValidateResult(badWire) == nil {
|
||||
t.Fatal("wire/process exit mismatch accepted")
|
||||
}
|
||||
}
|
||||
|
||||
type redactionEdge struct {
|
||||
Password *string `json:"password"`
|
||||
Plain string
|
||||
Ignored string `json:"-"`
|
||||
hidden string
|
||||
}
|
||||
|
||||
type redactCycle struct{ Next *redactCycle }
|
||||
|
||||
func TestFrameworkRedactionReflectionEdges(t *testing.T) {
|
||||
if redactEnvelope(nil) != nil {
|
||||
t.Fatal("redactEnvelope(nil) must be nil")
|
||||
}
|
||||
seen := map[redactVisit]struct{}{}
|
||||
redactReflectValue(reflect.Value{}, "", seen)
|
||||
|
||||
secret := "pointer-secret"
|
||||
fixture := &redactionEdge{Password: &secret, Plain: "token=plain-secret", Ignored: "ignored-secret", hidden: "hidden-secret"}
|
||||
redactReflectValue(reflect.ValueOf(fixture), "", seen) // not settable
|
||||
redactReflectValue(reflect.ValueOf(&fixture).Elem(), "", map[redactVisit]struct{}{})
|
||||
if fixture.Password == nil || *fixture.Password != redactedValue || !strings.Contains(fixture.Plain, redactedValue) {
|
||||
t.Fatalf("fixture not redacted: %+v", fixture)
|
||||
}
|
||||
if fixture.Ignored != "ignored-secret" || fixture.hidden != "hidden-secret" {
|
||||
t.Fatalf("ignored fields changed: %+v", fixture)
|
||||
}
|
||||
|
||||
array := [2]string{"password=array-one", "token=array-two"}
|
||||
redactReflectValue(reflect.ValueOf(&array).Elem(), "", map[redactVisit]struct{}{})
|
||||
if strings.Contains(strings.Join(array[:], ","), "array-") {
|
||||
t.Fatalf("array not redacted: %#v", array)
|
||||
}
|
||||
|
||||
cyclicMap := map[string]any{}
|
||||
cyclicMap["self"] = cyclicMap
|
||||
cyclicMap["password"] = "map-secret"
|
||||
redactReflectValue(reflect.ValueOf(cyclicMap), "", map[redactVisit]struct{}{})
|
||||
if cyclicMap["password"] != redactedValue {
|
||||
t.Fatalf("map secret=%v", cyclicMap["password"])
|
||||
}
|
||||
cyclicSlice := make([]any, 2)
|
||||
cyclicSlice[0] = cyclicSlice
|
||||
cyclicSlice[1] = "password=slice-secret"
|
||||
redactReflectValue(reflect.ValueOf(cyclicSlice), "", map[redactVisit]struct{}{})
|
||||
pointerCycle := &redactCycle{}
|
||||
pointerCycle.Next = pointerCycle
|
||||
redactReflectValue(reflect.ValueOf(pointerCycle), "", map[redactVisit]struct{}{})
|
||||
|
||||
unsettable := reflect.ValueOf("secret")
|
||||
if setRedactedValue(unsettable) {
|
||||
t.Fatal("unsettable value was redacted")
|
||||
}
|
||||
settableString := reflect.New(reflect.TypeOf("")).Elem()
|
||||
if !setRedactedValue(settableString) || settableString.String() != redactedValue {
|
||||
t.Fatal("settable string was not redacted")
|
||||
}
|
||||
settablePointer := reflect.New(reflect.TypeOf((*string)(nil))).Elem()
|
||||
if !setRedactedValue(settablePointer) || settablePointer.Elem().String() != redactedValue {
|
||||
t.Fatal("settable pointer was not redacted")
|
||||
}
|
||||
settableInt := reflect.New(reflect.TypeOf(0)).Elem()
|
||||
if setRedactedValue(settableInt) {
|
||||
t.Fatal("integer unexpectedly redacted")
|
||||
}
|
||||
|
||||
text := redactRecognizableSecrets("?=unchanged https://u:p@example.test?normal=ok&signature=s")
|
||||
if !strings.Contains(text, "?=unchanged") || strings.Contains(text, "u:p") || strings.Contains(text, "signature=s") {
|
||||
t.Fatalf("recognized secret redaction=%q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkEnvelopeNilAndNestedValidationEdges(t *testing.T) {
|
||||
if (&Pagination{}).Validate() == nil || (*Pagination)(nil).Validate() != nil {
|
||||
t.Fatal("pagination nil/two-state validation mismatch")
|
||||
}
|
||||
if (*ErrorInfo)(nil).Validate() == nil {
|
||||
t.Fatal("nil ErrorInfo accepted")
|
||||
}
|
||||
negative := int64(-1)
|
||||
for _, info := range []*ErrorInfo{
|
||||
{Type: "unknown"},
|
||||
{Type: "api", ExitCode: -1},
|
||||
{Type: "api", RetryAfterSeconds: &negative},
|
||||
} {
|
||||
if info.Validate() == nil {
|
||||
t.Fatalf("invalid ErrorInfo accepted: %+v", info)
|
||||
}
|
||||
}
|
||||
if (*PartialData)(nil).Validate() != nil {
|
||||
t.Fatal("nil PartialData should be compatible")
|
||||
}
|
||||
if (*OperationInfo)(nil).ValidateNextCommand() != nil || (*OperationInfo)(nil).ValidateTimeoutState() != nil || (*OperationInfo)(nil).Validate() != nil {
|
||||
t.Fatal("nil operation should validate")
|
||||
}
|
||||
env := NewSuccessEnvelope(nil)
|
||||
env.Meta = &Meta{Operation: &OperationInfo{NextCommand: " bad "}, Pagination: &Pagination{}}
|
||||
if env.Validate() == nil {
|
||||
t.Fatal("nested invalid metadata accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkResultStoreLifecycleEdges(t *testing.T) {
|
||||
if _, ok := resultStoreFromContext(nil); ok {
|
||||
t.Fatal("nil context had a store")
|
||||
}
|
||||
ctx, store := WithResultStore(nil)
|
||||
ctx2, same := WithResultStore(ctx)
|
||||
if ctx2 != ctx || same != store {
|
||||
t.Fatal("WithResultStore did not reuse existing store")
|
||||
}
|
||||
if err := StoreResult(context.Background(), Success(nil)); err == nil {
|
||||
t.Fatal("StoreResult without root store succeeded")
|
||||
}
|
||||
if err := StoreResult(ctx, Success(map[string]any{"id": "x"})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := StoreResult(ctx, Success(nil)); err == nil {
|
||||
t.Fatal("duplicate result accepted")
|
||||
}
|
||||
if _, _, err := EmitStoredResult(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain := &cobra.Command{Use: "plain"}
|
||||
if _, _, err := EmitStoredResult(plain); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain.SetContext(ctx)
|
||||
if _, _, err := EmitStoredResult(plain); err == nil {
|
||||
t.Fatal("legacy command emitted unified result")
|
||||
}
|
||||
|
||||
active := &cobra.Command{Use: "active"}
|
||||
active.SetContext(ctx)
|
||||
SetCommandRollout(active, RolloutUnifiedActive)
|
||||
var out bytes.Buffer
|
||||
active.SetOut(&out)
|
||||
code, emitted, err := EmitStoredResult(active)
|
||||
if err != nil || code != 0 || !emitted {
|
||||
t.Fatalf("EmitStoredResult=(%d,%v,%v)", code, emitted, err)
|
||||
}
|
||||
code, emitted, err = EmitStoredResult(active)
|
||||
if err != nil || code != 0 || !emitted {
|
||||
t.Fatalf("second EmitStoredResult=(%d,%v,%v)", code, emitted, err)
|
||||
}
|
||||
if code, attempted, emitted, risk := StoredEmissionState(store); code != 0 || !attempted || !emitted || !risk {
|
||||
t.Fatalf("state=(%d,%v,%v,%v)", code, attempted, emitted, risk)
|
||||
}
|
||||
if code, ok := StoredExitCode(store); code != 0 || !ok {
|
||||
t.Fatalf("StoredExitCode=(%d,%v)", code, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkResultStoreNilStateAndWriteFailure(t *testing.T) {
|
||||
code, attempted, emitted, risk := StoredEmissionState(nil)
|
||||
if code != 0 || attempted || emitted || risk {
|
||||
t.Fatalf("nil state=(%d,%v,%v,%v)", code, attempted, emitted, risk)
|
||||
}
|
||||
if code, ok := StoredExitCode(nil); code != 0 || ok {
|
||||
t.Fatalf("nil exit=(%d,%v)", code, ok)
|
||||
}
|
||||
ctx, store := WithResultStore(context.Background())
|
||||
if err := StoreResult(ctx, Success(nil)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := &cobra.Command{Use: "active"}
|
||||
cmd.SetContext(ctx)
|
||||
SetCommandRollout(cmd, RolloutUnifiedActive)
|
||||
cmd.SetOut(errorWriter{})
|
||||
if code, emitted, err := EmitStoredResult(cmd); code != exitCodeInternal || emitted || err == nil {
|
||||
t.Fatalf("write failure=(%d,%v,%v)", code, emitted, err)
|
||||
}
|
||||
if code, attempted, emitted, risk := StoredEmissionState(store); code != exitCodeInternal || !attempted || emitted || risk {
|
||||
t.Fatalf("failed state=(%d,%v,%v,%v)", code, attempted, emitted, risk)
|
||||
}
|
||||
if _, err := EnvelopeFromResult(nil); err == nil {
|
||||
t.Fatal("EnvelopeFromResult(nil) succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
type errorWriter struct{}
|
||||
|
||||
func (errorWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
|
||||
|
||||
func TestFrameworkEmitterAndRolloutEdges(t *testing.T) {
|
||||
if jsonShorthandActive(nil) {
|
||||
t.Fatal("nil command activated --json")
|
||||
}
|
||||
if err := WriteEnvelope(nil, nil, FormatJSON); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := &cobra.Command{Use: "cmd"}
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(errorWriter{})
|
||||
cmd.Flags().String("format", "unknown", "")
|
||||
if err := WriteEnvelope(cmd, NewSuccessEnvelope(nil), FormatJSON); err == nil {
|
||||
t.Fatal("warning write error was ignored")
|
||||
}
|
||||
if _, _, err := emitResult(nil, nil); err == nil {
|
||||
t.Fatal("nil result accepted")
|
||||
}
|
||||
broken := NewSuccessEnvelope(make(chan int))
|
||||
if err := WriteEnvelope(&cobra.Command{Use: "broken"}, broken, FormatJSON); err == nil {
|
||||
t.Fatal("WriteEnvelope accepted unrenderable data")
|
||||
}
|
||||
if _, _, err := emitResult(&cobra.Command{Use: "broken"}, Success(make(chan int))); err == nil {
|
||||
t.Fatal("emitResult accepted unrenderable data")
|
||||
}
|
||||
|
||||
human := &cobra.Command{Use: "human"}
|
||||
human.Flags().String("format", "table", "")
|
||||
human.SetErr(errorWriter{})
|
||||
if _, risk, err := emitResult(human, Failure(&ErrorInfo{Type: "api", Message: "boom"})); err == nil || risk {
|
||||
t.Fatalf("human failure write=(risk=%v, err=%v)", risk, err)
|
||||
}
|
||||
short := &shortWriter{}
|
||||
human.SetErr(short)
|
||||
if _, risk, err := emitResult(human, Failure(&ErrorInfo{Type: "api"})); !errors.Is(err, io.ErrShortWrite) || !risk {
|
||||
t.Fatalf("human short write=(risk=%v, err=%v)", risk, err)
|
||||
}
|
||||
|
||||
machine := &cobra.Command{Use: "machine"}
|
||||
machine.SetOut(errorWriter{})
|
||||
if _, risk, err := emitResult(machine, Success(nil)); err == nil || risk {
|
||||
t.Fatalf("machine write=(risk=%v, err=%v)", risk, err)
|
||||
}
|
||||
machine.SetOut(&shortWriter{})
|
||||
if _, risk, err := emitResult(machine, Success(nil)); !errors.Is(err, io.ErrShortWrite) || !risk {
|
||||
t.Fatalf("machine short write=(risk=%v, err=%v)", risk, err)
|
||||
}
|
||||
|
||||
if err := renderEnvelope(io.Discard, nil, nil, FormatJSON, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fallback bytes.Buffer
|
||||
if err := renderEnvelopeInto(&fallback, io.Discard, NewSuccessEnvelope(map[string]any{"id": "x"}), Format("unexpected"), "", ""); err != nil || !strings.Contains(fallback.String(), `"outcome"`) {
|
||||
t.Fatalf("unknown format fallback=%q err=%v", fallback.String(), err)
|
||||
}
|
||||
if got := ExitCodeForEnvelope(&Envelope{Outcome: OutcomeFailure, Error: &ErrorInfo{Type: "permission"}}); got != exitCodePermission {
|
||||
t.Fatalf("permission exit=%d", got)
|
||||
}
|
||||
var ndjson bytes.Buffer
|
||||
if err := renderEnvelopeInto(&ndjson, io.Discard, Failure(&ErrorInfo{Type: "api"}).envelope(), FormatNDJSON, "", ""); err != nil || strings.Count(strings.TrimSpace(ndjson.String()), "\n") != 0 {
|
||||
t.Fatalf("NDJSON failure=%q err=%v", ndjson.String(), err)
|
||||
}
|
||||
if _, err := writeAllCount(&shortWriter{}, []byte("payload")); !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("short write err=%v", err)
|
||||
}
|
||||
|
||||
if _, err := ParseRolloutState("bad"); err == nil {
|
||||
t.Fatal("invalid rollout parsed")
|
||||
}
|
||||
if err := ValidateRolloutTransition("bad", RolloutLegacyOnly, false); err == nil {
|
||||
t.Fatal("invalid from state accepted")
|
||||
}
|
||||
if err := ValidateRolloutTransition(RolloutLegacyOnly, "bad", false); err == nil {
|
||||
t.Fatal("invalid to state accepted")
|
||||
}
|
||||
if rolloutRank("bad") != -1 {
|
||||
t.Fatal("unknown rollout rank")
|
||||
}
|
||||
SetCommandRollout(nil, RolloutUnifiedActive)
|
||||
if err := ValidateUnifiedFormat(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("invalid rollout did not panic")
|
||||
}
|
||||
}()
|
||||
SetCommandRollout(&cobra.Command{Use: "bad"}, "bad")
|
||||
}
|
||||
|
||||
func TestFrameworkMCPAdapterErrors(t *testing.T) {
|
||||
if _, err := AdaptMCP(nil); err == nil {
|
||||
t.Fatal("AdaptMCP(nil) succeeded")
|
||||
}
|
||||
originalMarshal, originalUnmarshal := marshalMCPResult, unmarshalMCPResult
|
||||
t.Cleanup(func() { marshalMCPResult, unmarshalMCPResult = originalMarshal, originalUnmarshal })
|
||||
marshalMCPResult = func(any) ([]byte, error) { return nil, errors.New("marshal") }
|
||||
if _, err := AdaptMCP(Success(nil)); err == nil {
|
||||
t.Fatal("MCP marshal failure ignored")
|
||||
}
|
||||
marshalMCPResult = func(any) ([]byte, error) { return []byte("{"), nil }
|
||||
unmarshalMCPResult = originalUnmarshal
|
||||
if _, err := AdaptMCP(Success(nil)); err == nil {
|
||||
t.Fatal("MCP decode failure ignored")
|
||||
}
|
||||
}
|
||||
|
||||
type shortWriter struct{}
|
||||
|
||||
func (*shortWriter) Write(p []byte) (int, error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return len(p) - 1, nil
|
||||
}
|
||||
@@ -8,6 +8,11 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
marshalMCPResult = json.Marshal
|
||||
unmarshalMCPResult = json.Unmarshal
|
||||
)
|
||||
|
||||
// MCPResult is the protocol-neutral projection consumed by MCP transports.
|
||||
// Both CLI and MCP adapters start from the same immutable CommandResult; MCP
|
||||
// handlers must not rebuild a second business envelope.
|
||||
@@ -25,12 +30,12 @@ func AdaptMCP(result CommandResult) (*MCPResult, error) {
|
||||
return nil, err
|
||||
}
|
||||
env = redactEnvelope(env)
|
||||
raw, err := json.Marshal(env)
|
||||
raw, err := marshalMCPResult(env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("output: marshal MCP result: %w", err)
|
||||
}
|
||||
var structured map[string]any
|
||||
if err := json.Unmarshal(raw, &structured); err != nil {
|
||||
if err := unmarshalMCPResult(raw, &structured); err != nil {
|
||||
return nil, fmt.Errorf("output: decode MCP structured result: %w", err)
|
||||
}
|
||||
return &MCPResult{
|
||||
|
||||
@@ -163,9 +163,6 @@ func redactRecognizableSecrets(text string) string {
|
||||
text = urlCredentialsPattern.ReplaceAllString(text, `${1}`+redactedValue+`@`)
|
||||
text = urlQueryValuePattern.ReplaceAllStringFunc(text, func(parameter string) string {
|
||||
equals := strings.IndexByte(parameter, '=')
|
||||
if equals < 2 {
|
||||
return parameter
|
||||
}
|
||||
key, err := url.QueryUnescape(parameter[1:equals])
|
||||
if err != nil || !isSensitiveQueryKey(key) {
|
||||
return parameter
|
||||
|
||||
@@ -3,7 +3,26 @@
|
||||
|
||||
package devapp
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"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/pkg/edition"
|
||||
)
|
||||
|
||||
type paginationCaller struct{ text string }
|
||||
|
||||
func (c *paginationCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.text}}}, nil
|
||||
}
|
||||
func (*paginationCaller) Format() string { return "json" }
|
||||
func (*paginationCaller) DryRun() bool { return false }
|
||||
func (*paginationCaller) Fields() string { return "" }
|
||||
func (*paginationCaller) JQ() string { return "" }
|
||||
|
||||
func TestDevAppListProjectionPreservesPaginationEvidence(t *testing.T) {
|
||||
items := []map[string]any{{"id": "one"}}
|
||||
@@ -28,4 +47,32 @@ func TestDevAppListProjectionPreservesPaginationEvidence(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
if got := devAppPaginationCandidates(nil); got != nil {
|
||||
t.Fatalf("nil candidates=%#v", got)
|
||||
}
|
||||
deep := map[string]any{"content": map[string]any{"result": map[string]any{"hasMore": true, "nextCursor": "deep"}}}
|
||||
if got := devAppListProjection(deep, "items", nil); got["nextCursor"] != "deep" {
|
||||
t.Fatalf("deep projection=%#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkPaginatedShortcutExecutionKeepsCursorEvidence(t *testing.T) {
|
||||
caller := &paginationCaller{text: `{"content":{"result":{"hasMore":true,"nextCursor":"next","list":[]}}}`}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
helpers.GetFormatter().SetWriters(&bytes.Buffer{}, &bytes.Buffer{})
|
||||
for _, tc := range []struct {
|
||||
declaration shortcut.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{ListApp, nil},
|
||||
{PermissionList, []string{"--unified-app-id", "app"}},
|
||||
{EventList, []string{"--unified-app-id", "app"}},
|
||||
{VersionList, []string{"--unified-app-id", "app"}},
|
||||
} {
|
||||
cmd := corecmd.New(shortcut.FromShortcut(tc.declaration))
|
||||
cmd.SetArgs(tc.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("%s: %v", tc.declaration.Command, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,13 +20,51 @@ type dualValidateCaller struct {
|
||||
dryRun bool
|
||||
calls int
|
||||
text string
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *dualValidateCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls++
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.text}}}, nil
|
||||
}
|
||||
|
||||
func TestFrameworkDualValidateBackendAndShadowErrors(t *testing.T) {
|
||||
caller := &dualValidateCaller{format: "json", text: `{"id":"x"}`, err: context.DeadlineExceeded}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
cmd := mount(Shortcut{Service: "sample", Command: "+compat", OutputRollout: output.RolloutDualValidate, Execute: func(rt *RuntimeContext) error { return rt.CallMCP("get", nil) }})
|
||||
root.AddCommand(cmd)
|
||||
root.SetArgs([]string{cmd.Name()})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("backend error swallowed")
|
||||
}
|
||||
|
||||
original := validateShadowResult
|
||||
t.Cleanup(func() { validateShadowResult = original })
|
||||
validateShadowResult = func(output.CommandResult) error { return context.Canceled }
|
||||
direct := &cobra.Command{Use: "+direct"}
|
||||
direct.SetContext(context.Background())
|
||||
output.SetCommandRollout(direct, output.RolloutDualValidate)
|
||||
if err := RuntimeContextForTest(direct, Shortcut{Service: "sample", Command: "+direct"}).Output(map[string]any{"id": "x"}); err == nil {
|
||||
t.Fatal("Output shadow validation error swallowed")
|
||||
}
|
||||
caller.err = nil
|
||||
root.SetArgs([]string{cmd.Name()})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("shadow validation error swallowed")
|
||||
}
|
||||
caller.dryRun = true
|
||||
root.SetArgs([]string{cmd.Name(), "--dry-run"})
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("dry-run shadow validation error swallowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *dualValidateCaller) Format() string { return c.format }
|
||||
func (c *dualValidateCaller) DryRun() bool { return c.dryRun }
|
||||
func (c *dualValidateCaller) Fields() string { return "" }
|
||||
|
||||
@@ -27,6 +27,8 @@ import (
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
var validateShadowResult = output.ValidateResult
|
||||
|
||||
// RuntimeContext is handed to a Shortcut's Validate and Execute hooks. It wraps
|
||||
// the cobra command and exposes typed flag accessors plus a single CallMCP entry
|
||||
// point so shortcut authors never touch cobra/executor plumbing directly.
|
||||
@@ -152,7 +154,7 @@ func (rt *RuntimeContext) CallMCP(tool string, params map[string]any) error {
|
||||
"arguments": params,
|
||||
})
|
||||
if rt.DryRun() {
|
||||
if err := output.ValidateResult(rt.resultForPayload(tool, preview)); err != nil {
|
||||
if err := validateShadowResult(rt.resultForPayload(tool, preview)); err != nil {
|
||||
return err
|
||||
}
|
||||
// The legacy caller owns dry-run presentation (including its human
|
||||
@@ -165,7 +167,7 @@ func (rt *RuntimeContext) CallMCP(tool string, params map[string]any) error {
|
||||
return err
|
||||
}
|
||||
data := legacyMCPPayload(text)
|
||||
if err := output.ValidateResult(rt.resultForPayload(tool, data)); err != nil {
|
||||
if err := validateShadowResult(rt.resultForPayload(tool, data)); err != nil {
|
||||
return err
|
||||
}
|
||||
// dual_validate changes no external bytes: it renders the once-fetched
|
||||
@@ -305,7 +307,7 @@ func (rt *RuntimeContext) Output(payload any) error {
|
||||
return output.StoreResult(rt.cmd.Context(), rt.resultForPayload("", payload))
|
||||
}
|
||||
if output.CommandRollout(rt.cmd) == output.RolloutDualValidate {
|
||||
if err := output.ValidateResult(rt.resultForPayload("", payload)); err != nil {
|
||||
if err := validateShadowResult(rt.resultForPayload("", payload)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
package shortcut
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
@@ -84,3 +87,90 @@ func TestCrossPlatformCoverageRuntimeWriteDataRemainingBranches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkShortcutUnifiedOutputAndProjectionEdges(t *testing.T) {
|
||||
oldCaller := helpers.GetCaller()
|
||||
t.Cleanup(func() { helpers.InitDeps(oldCaller) })
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd := &cobra.Command{Use: "+read"}
|
||||
cmd.SetContext(ctx)
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
rt := RuntimeContextForTest(cmd, Shortcut{Service: "sample", Command: "+read", Safety: contract.SafetySpec{Effect: "read"}})
|
||||
if err := rt.Output(map[string]any{"id": "x"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &runtimeReadCoverageCaller{text: `{"success":true,"id":"server"}`}
|
||||
helpers.InitDeps(caller)
|
||||
callCtx, _ := output.WithResultStore(context.Background())
|
||||
callCmd := &cobra.Command{Use: "+call"}
|
||||
callCmd.SetContext(callCtx)
|
||||
output.SetCommandRollout(callCmd, output.RolloutUnifiedActive)
|
||||
callRT := RuntimeContextForTest(callCmd, Shortcut{Service: "sample", Command: "+call", Safety: contract.SafetySpec{Effect: "read"}})
|
||||
if err := callRT.CallMCP("get", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller.err = errors.New("backend")
|
||||
errorCtx, _ := output.WithResultStore(context.Background())
|
||||
errorCmd := &cobra.Command{Use: "+error"}
|
||||
errorCmd.SetContext(errorCtx)
|
||||
output.SetCommandRollout(errorCmd, output.RolloutUnifiedActive)
|
||||
errorRT := RuntimeContextForTest(errorCmd, Shortcut{Service: "sample", Command: "+error", Safety: contract.SafetySpec{Effect: "read"}})
|
||||
if err := errorRT.CallMCP("get", nil); err == nil {
|
||||
t.Fatal("backend error swallowed")
|
||||
}
|
||||
caller.err = nil
|
||||
|
||||
dryCtx, _ := output.WithResultStore(context.Background())
|
||||
dryCmd := &cobra.Command{Use: "+dry"}
|
||||
dryCmd.SetContext(dryCtx)
|
||||
dryCmd.Flags().Bool("dry-run", true, "")
|
||||
_ = dryCmd.Flags().Set("dry-run", "true")
|
||||
output.SetCommandRollout(dryCmd, output.RolloutUnifiedActive)
|
||||
dryRT := RuntimeContextForTest(dryCmd, Shortcut{Service: "sample", Command: "+dry", Safety: contract.SafetySpec{Effect: "read"}})
|
||||
if err := dryRT.CallMCP("get", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dualCmd := &cobra.Command{Use: "+dual"}
|
||||
dualCmd.SetContext(context.Background())
|
||||
dualCmd.SetOut(&bytes.Buffer{})
|
||||
output.SetCommandRollout(dualCmd, output.RolloutDualValidate)
|
||||
dualRT := RuntimeContextForTest(dualCmd, Shortcut{Service: "sample", Command: "+dual", Safety: contract.SafetySpec{Effect: "read"}})
|
||||
if err := dualRT.Output(map[string]any{"success": "false"}); err != nil {
|
||||
t.Fatalf("dual validation should accept a typed failure result: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
payload any
|
||||
outcome output.Outcome
|
||||
}{
|
||||
{"scalar", "value", output.OutcomeSuccess},
|
||||
{"nested success", map[string]any{"content": map[string]any{"success": true}}, output.OutcomeSuccess},
|
||||
{"failure fallback", map[string]any{"success": false}, output.OutcomeFailure},
|
||||
{"failure message", map[string]any{"content": map[string]any{"success": false, "errorMessage": "bad"}}, output.OutcomeFailure},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := shortcutCommandResult(tc.payload, output.WithDryRun())
|
||||
if result.Outcome() != tc.outcome {
|
||||
t.Fatalf("outcome=%s", result.Outcome())
|
||||
}
|
||||
})
|
||||
}
|
||||
if hasExplicitShortcutSuccess("scalar") || hasExplicitShortcutSuccess(map[string]any{"success": false}) || !hasExplicitShortcutSuccess(map[string]any{"content": map[string]any{"success": true}}) {
|
||||
t.Fatal("explicit success classification mismatch")
|
||||
}
|
||||
for _, shortcut := range []Shortcut{
|
||||
{Safety: contract.SafetySpec{Effect: "write"}},
|
||||
{Safety: contract.SafetySpec{Effect: "destructive"}},
|
||||
{Risk: RiskHighWrite},
|
||||
{Risk: RiskRead},
|
||||
} {
|
||||
probe := RuntimeContextForTest(&cobra.Command{Use: "probe"}, shortcut)
|
||||
_ = probe.isWriteShortcut()
|
||||
}
|
||||
devRT := RuntimeContextForTest(&cobra.Command{Use: "dev"}, Shortcut{Service: "devapp", Command: "+get"})
|
||||
if got := devRT.resultForPayload("get_dev_app", map[string]any{"success": true}); got.Outcome() != output.OutcomeSuccess {
|
||||
t.Fatalf("devapp result=%s", got.Outcome())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user