Add invocation coordination and diagnostics
This commit is contained in:
264
execute_external_test.go
Normal file
264
execute_external_test.go
Normal file
@@ -0,0 +1,264 @@
|
||||
package commandkit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
||||
type input struct {
|
||||
name commandkit.Supplied[string]
|
||||
ids commandkit.Supplied[[]string]
|
||||
}
|
||||
var inputs []input
|
||||
var contexts []context.Context
|
||||
var streams []commandkit.Streams
|
||||
ctx := context.WithValue(context.Background(), struct{}{}, "invocation")
|
||||
var out, diagnostic bytes.Buffer
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
Name: "run",
|
||||
Flags: []commandkit.Flag[input]{
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "name"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(target *input, value commandkit.Supplied[string]) { target.name = value }),
|
||||
commandkit.RepeatFlag(commandkit.FlagMeta{Name: "id"}, nil, func(value string) (string, error) { return value, nil }, func(target *input, value commandkit.Supplied[[]string]) { target.ids = value }),
|
||||
},
|
||||
Run: func(invocation commandkit.Invocation, value input) commandkit.Outcome {
|
||||
inputs = append(inputs, value)
|
||||
contexts = append(contexts, invocation.Context)
|
||||
streams = append(streams, invocation.Streams)
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := app.Execute(ctx, []string{"run", "--name=first", "--id=a,b", "--id=c"}, commandkit.Streams{Out: &out, Err: &diagnostic})
|
||||
second := app.Execute(ctx, []string{"run"}, commandkit.Streams{Out: &out, Err: &diagnostic})
|
||||
third := app.Execute(ctx, []string{"run"}, commandkit.Streams{})
|
||||
if first.Kind != commandkit.OutcomeSuccess || second.Kind != commandkit.OutcomeSuccess || third.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("outcomes = %#v, %#v, and %#v", first, second, third)
|
||||
}
|
||||
if len(inputs) != 3 || inputs[0].name.Value != "first" || !inputs[0].name.Set || len(inputs[0].ids.Value) != 2 || inputs[0].ids.Value[0] != "a,b" || inputs[0].ids.Value[1] != "c" {
|
||||
t.Fatalf("first input = %#v", inputs)
|
||||
}
|
||||
if inputs[1].name.Set || inputs[1].name.Value != "" || inputs[1].ids.Set {
|
||||
t.Fatalf("second invocation inherited state: %#v", inputs[1])
|
||||
}
|
||||
if streams[2].Out != io.Discard || streams[2].Err != io.Discard {
|
||||
t.Fatalf("nil streams were not replaced with io.Discard: %#v", streams[2])
|
||||
}
|
||||
if contexts[0] != ctx || streams[0].Out != &out || streams[0].Err != &diagnostic {
|
||||
t.Fatalf("handler invocation context or streams were not forwarded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReturnsUsageForParsingRequirementsAndInputChecks(t *testing.T) {
|
||||
type input struct{ count commandkit.Supplied[int] }
|
||||
parseFailure := errors.New("bad count")
|
||||
checkFailure := errors.New("incompatible inputs")
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
Name: "run",
|
||||
Flags: []commandkit.Flag[input]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "count", Required: true}, nil, func(value string) (int, error) {
|
||||
if value == "bad" {
|
||||
return 0, parseFailure
|
||||
}
|
||||
return 0, nil
|
||||
}, func(target *input, value commandkit.Supplied[int]) { target.count = value })},
|
||||
Arguments: []commandkit.Argument[input]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "id"}, true, func(value string) (string, error) { return value, nil }, func(*input, commandkit.Supplied[string]) {})},
|
||||
CheckInput: func(input) error { return checkFailure },
|
||||
Run: func(commandkit.Invocation, input) commandkit.Outcome { return commandkit.Success() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var diagnostic bytes.Buffer
|
||||
missing := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
|
||||
if missing.Kind != commandkit.OutcomeUsage || missing.Status != 2 || !strings.Contains(diagnostic.String(), "Usage: example run") {
|
||||
t.Fatalf("missing required input = %#v, diagnostics %q", missing, diagnostic.String())
|
||||
}
|
||||
malformed := app.Execute(context.Background(), []string{"run", "--count=bad", "id"}, commandkit.Streams{Err: &diagnostic})
|
||||
if malformed.Kind != commandkit.OutcomeUsage || !errors.Is(malformed.Cause, parseFailure) {
|
||||
t.Fatalf("parser failure = %#v", malformed)
|
||||
}
|
||||
checked := app.Execute(context.Background(), []string{"run", "--count=1", "id"}, commandkit.Streams{Err: &diagnostic})
|
||||
if checked.Kind != commandkit.OutcomeUsage || !errors.Is(checked.Cause, checkFailure) {
|
||||
t.Fatalf("input check failure = %#v", checked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
||||
domainCause := errors.New("domain rejected the request")
|
||||
warning := commandkit.MessageDiagnostic(configkit.SeverityWarning, "review needed")
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
outcome := commandkit.Domain("sample", 2, domainCause)
|
||||
outcome.Diagnostics = []commandkit.Diagnostic{warning}
|
||||
return outcome
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var diagnostic bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
|
||||
if outcome.Kind != commandkit.OutcomeDomain || outcome.Domain != "sample" || outcome.Status != 2 || !errors.Is(outcome.Cause, domainCause) {
|
||||
t.Fatalf("domain outcome changed: %#v", outcome)
|
||||
}
|
||||
if diagnostic.String() != "review needed\ndomain rejected the request\n" || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
|
||||
t.Fatalf("warning delivery = %q, %#v", diagnostic.String(), outcome)
|
||||
}
|
||||
warningOnly, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "warn",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
result := commandkit.Success()
|
||||
result.Diagnostics = []commandkit.Diagnostic{warning}
|
||||
return result
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err = commandkit.New(commandkit.Identity{Name: "example"}, warningOnly)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diagnostic.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"warn"}, commandkit.Streams{Err: &diagnostic})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || diagnostic.String() != "review needed\n" {
|
||||
t.Fatalf("warning-only outcome = %#v, diagnostics %q", outcome, diagnostic.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDoesNotRepeatCompletedDiagnosticsOrPresentedCauses(t *testing.T) {
|
||||
cause := errors.New("already shown")
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
outcome := commandkit.Domain("sample", 2, cause)
|
||||
outcome.Diagnostics = []commandkit.Diagnostic{{Severity: configkit.SeverityError, Write: func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "presented\n")
|
||||
return err
|
||||
}}}
|
||||
return outcome
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var diagnostic bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
|
||||
if outcome.Kind != commandkit.OutcomeDomain || diagnostic.String() != "presented\n" {
|
||||
t.Fatalf("presented cause was duplicated: %#v, %q", outcome, diagnostic.String())
|
||||
}
|
||||
|
||||
diagnostic.Reset()
|
||||
completed := commandkit.Domain("sample", 2, cause)
|
||||
completed.DiagnosticDelivery = commandkit.DeliveryComplete
|
||||
command, err = commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "done", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return completed }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err = commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outcome = app.Execute(context.Background(), []string{"done"}, commandkit.Streams{Err: &diagnostic})
|
||||
if diagnostic.Len() != 0 || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
|
||||
t.Fatalf("completed diagnostics were repeated: %#v, %q", outcome, diagnostic.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
|
||||
operationFailure := context.Canceled
|
||||
writeFailure := errors.New("writer failed")
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
outcome := commandkit.Failure(operationFailure)
|
||||
outcome.ReportIncomplete = true
|
||||
return outcome
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: failingWriter{err: writeFailure}})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete || !outcome.ReportIncomplete || !errors.Is(outcome.Cause, operationFailure) || !errors.Is(outcome.DeliveryError, writeFailure) {
|
||||
t.Fatalf("combined operation and delivery failure = %#v", outcome)
|
||||
}
|
||||
|
||||
short := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: shortWriter{}})
|
||||
if !errors.Is(short.DeliveryError, io.ErrShortWrite) || short.DiagnosticDelivery != commandkit.DeliveryIncomplete {
|
||||
t.Fatalf("short write was not detected: %#v", short)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHelpSkipsRequiredInputsAndRejectsNilContext(t *testing.T) {
|
||||
type input struct{ id commandkit.Supplied[string] }
|
||||
factoryCalls, checkCalls, runCalls := 0, 0, 0
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
Name: "run",
|
||||
NewInput: func() input {
|
||||
factoryCalls++
|
||||
return input{}
|
||||
},
|
||||
Arguments: []commandkit.Argument[input]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "id"}, true, func(value string) (string, error) { return value, nil }, func(target *input, value commandkit.Supplied[string]) { target.id = value })},
|
||||
CheckInput: func(input) error { checkCalls++; return nil },
|
||||
Run: func(commandkit.Invocation, input) commandkit.Outcome { runCalls++; return commandkit.Success() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
help := app.Execute(context.Background(), []string{"run", "--help"}, commandkit.Streams{})
|
||||
if help.Kind != commandkit.OutcomeSuccess || factoryCalls != 0 || checkCalls != 0 || runCalls != 0 {
|
||||
t.Fatalf("help request invoked execution path: %#v calls=%d/%d/%d", help, factoryCalls, checkCalls, runCalls)
|
||||
}
|
||||
invalid := app.Execute(nil, []string{"run"}, commandkit.Streams{})
|
||||
if invalid.Kind != commandkit.OutcomeOperational || invalid.Status != 1 || invalid.Cause == nil {
|
||||
t.Fatalf("nil context outcome = %#v", invalid)
|
||||
}
|
||||
}
|
||||
|
||||
type failingWriter struct{ err error }
|
||||
|
||||
func (w failingWriter) Write([]byte) (int, error) { return 0, w.err }
|
||||
|
||||
type shortWriter struct{}
|
||||
|
||||
func (shortWriter) Write(data []byte) (int, error) {
|
||||
if len(data) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return len(data) - 1, nil
|
||||
}
|
||||
Reference in New Issue
Block a user