diff --git a/internal/app/framework_coverage_test.go b/internal/app/framework_coverage_test.go new file mode 100644 index 00000000..3f7cecf7 --- /dev/null +++ b/internal/app/framework_coverage_test.go @@ -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()) + } + }) + } +} diff --git a/internal/app/root.go b/internal/app/root.go index d7868a24..1c61f686 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -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. diff --git a/internal/app/signal.go b/internal/app/signal.go index 5aa332e3..f60e1198 100644 --- a/internal/app/signal.go +++ b/internal/app/signal.go @@ -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)) } } diff --git a/internal/app/signal_test.go b/internal/app/signal_test.go index 1759f3fc..1d2fdc4c 100644 --- a/internal/app/signal_test.go +++ b/internal/app/signal_test.go @@ -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) diff --git a/internal/corecmd/contract/types.go b/internal/corecmd/contract/types.go index b3056142..a7b210a9 100644 --- a/internal/corecmd/contract/types.go +++ b/internal/corecmd/contract/types.go @@ -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 } diff --git a/internal/corecmd/contract/types_coverage_test.go b/internal/corecmd/contract/types_coverage_test.go index ebb9b1b6..8c317bc1 100644 --- a/internal/corecmd/contract/types_coverage_test.go +++ b/internal/corecmd/contract/types_coverage_test.go @@ -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 { diff --git a/internal/corecmd/contract_decl_test.go b/internal/corecmd/contract_decl_test.go index cbef5718..081b6c04 100644 --- a/internal/corecmd/contract_decl_test.go +++ b/internal/corecmd/contract_decl_test.go @@ -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{ diff --git a/internal/corecmd/contractfinal/final_test.go b/internal/corecmd/contractfinal/final_test.go index 07957103..5753408c 100644 --- a/internal/corecmd/contractfinal/final_test.go +++ b/internal/corecmd/contractfinal/final_test.go @@ -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) + } +} diff --git a/internal/corecmd/result_invoke_test.go b/internal/corecmd/result_invoke_test.go index 07e7002b..428262a2 100644 --- a/internal/corecmd/result_invoke_test.go +++ b/internal/corecmd/result_invoke_test.go @@ -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) + } + }) + } +} diff --git a/internal/helpers/connect_daemon.go b/internal/helpers/connect_daemon.go index a0f386a1..bd2662c0 100644 --- a/internal/helpers/connect_daemon.go +++ b/internal/helpers/connect_daemon.go @@ -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 diff --git a/internal/helpers/connect_daemon_coverage_more_test.go b/internal/helpers/connect_daemon_coverage_more_test.go index 59a6d046..6f7f4659 100644 --- a/internal/helpers/connect_daemon_coverage_more_test.go +++ b/internal/helpers/connect_daemon_coverage_more_test.go @@ -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) + } + } +} diff --git a/internal/helpers/devapp_shared_result_test.go b/internal/helpers/devapp_shared_result_test.go index b9883528..27cf548c 100644 --- a/internal/helpers/devapp_shared_result_test.go +++ b/internal/helpers/devapp_shared_result_test.go @@ -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) + } +} diff --git a/internal/output/emitter.go b/internal/output/emitter.go index ab17c2c9..4c1bd22e 100644 --- a/internal/output/emitter.go +++ b/internal/output/emitter.go @@ -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: diff --git a/internal/output/formatter.go b/internal/output/formatter.go index b98625b5..4e862cee 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -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 diff --git a/internal/output/framework_coverage_test.go b/internal/output/framework_coverage_test.go new file mode 100644 index 00000000..3cec4e8e --- /dev/null +++ b/internal/output/framework_coverage_test.go @@ -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 +} diff --git a/internal/output/mcp.go b/internal/output/mcp.go index b3b3569c..5bc2fbf0 100644 --- a/internal/output/mcp.go +++ b/internal/output/mcp.go @@ -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{ diff --git a/internal/output/redaction.go b/internal/output/redaction.go index a1d95099..3a9bbb23 100644 --- a/internal/output/redaction.go +++ b/internal/output/redaction.go @@ -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 diff --git a/internal/shortcut/devapp/devapp_pagination_projection_test.go b/internal/shortcut/devapp/devapp_pagination_projection_test.go index f0917fb6..c137d5ff 100644 --- a/internal/shortcut/devapp/devapp_pagination_projection_test.go +++ b/internal/shortcut/devapp/devapp_pagination_projection_test.go @@ -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) + } + } } diff --git a/internal/shortcut/dual_validate_compat_test.go b/internal/shortcut/dual_validate_compat_test.go index 8cd51640..dcb6fe5c 100644 --- a/internal/shortcut/dual_validate_compat_test.go +++ b/internal/shortcut/dual_validate_compat_test.go @@ -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 "" } diff --git a/internal/shortcut/runner.go b/internal/shortcut/runner.go index 5938f06c..4a9983c6 100644 --- a/internal/shortcut/runner.go +++ b/internal/shortcut/runner.go @@ -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 } } diff --git a/internal/shortcut/runner_coverage_test.go b/internal/shortcut/runner_coverage_test.go index df4d1c33..e70d6f72 100644 --- a/internal/shortcut/runner_coverage_test.go +++ b/internal/shortcut/runner_coverage_test.go @@ -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()) + } +}