package cli import ( "bytes" "context" "errors" "fmt" "io" "log/slog" "os" "reflect" "strings" "syscall" "testing" "time" urfavecli "github.com/urfave/cli/v3" "go.auvem.com/corestack/lifecycle" ) func config() lifecycle.Config { return lifecycle.Config{Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Signals: make(chan os.Signal, 2), ShutdownTimeout: time.Second} } func command(action urfavecli.ActionFunc) *urfavecli.Command { return &urfavecli.Command{Name: "app", Writer: io.Discard, ErrWriter: io.Discard, Commands: []*urfavecli.Command{{Name: "inspect", Action: action, Flags: []urfavecli.Flag{&urfavecli.BoolFlag{Name: "verbose"}}}}} } func TestNativeLifecycleFlow(t *testing.T) { for _, fail := range []bool{false, true} { t.Run(fmt.Sprint(fail), func(t *testing.T) { var order []string boom := &os.PathError{Op: "inspect", Path: "db", Err: io.ErrUnexpectedEOF} var selected []lifecycle.Module for _, name := range []string{"cli-action", "cli-action-", "database"} { selected = append(selected, lifecycle.Module{Name: name, Setup: func(context.Context) error { order = append(order, "setup "+name); return nil }, Run: func(context.Context) error { t.Error("one-off service started"); return nil }, Teardown: func(context.Context) error { order = append(order, "close "+name); return nil }}) } cliRoot := command(Action(config(), selected, func(_ context.Context, cmd *urfavecli.Command) error { if !cmd.Bool("verbose") { t.Error("native flag missing") } order = append(order, "inspect") if fail { return boom } return nil })) cliRoot.Commands = append(cliRoot.Commands, &urfavecli.Command{Name: "unused", Action: Serve(config(), []lifecycle.Module{{Name: "unused", Setup: func(context.Context) error { t.Error("unselected setup"); return nil }, Teardown: func(context.Context) error { return nil }}})}) code, err := Run(context.Background(), cliRoot, []string{"app", "inspect", "--verbose"}) if fail && (code != 1 || !errors.Is(err, boom)) || !fail && (code != 0 || err != nil) { t.Fatalf("Run = %d, %v", code, err) } want := []string{"setup cli-action", "setup cli-action-", "setup database", "inspect", "close database", "close cli-action-", "close cli-action"} if !reflect.DeepEqual(order, want) { t.Fatalf("order %v", order) } }) } } func TestNativeUsageAndHelp(t *testing.T) { for _, tc := range []struct { args string code int }{ {"", 0}, {"--help", 0}, {"inspect --help", 0}, {"help inspect", 0}, {"group", 0}, {"group --help", 0}, {"missing", 2}, {"help missing", 2}, {"group missing", 2}, {"group help missing", 2}, {"--bad", 2}, {"inspect --bad", 2}, {"inspect --count", 2}, {"inspect --count nope", 2}, {"inspect", 2}, {"help --bad", 1}, {"h --bad", 1}, {"group help --bad", 1}, {"group h --bad", 1}, } { t.Run(tc.args, func(t *testing.T) { cliRoot := command(Action(config(), []lifecycle.Module{{Name: "db", Setup: func(context.Context) error { t.Error("setup during usage/help"); return nil }, Teardown: func(context.Context) error { return nil }}}, func(context.Context, *urfavecli.Command) error { t.Error("action during usage/help"); return nil })) cliRoot.Commands[0].Flags = append(cliRoot.Commands[0].Flags, &urfavecli.IntFlag{Name: "count", Required: true}) cliRoot.Commands = append(cliRoot.Commands, &urfavecli.Command{Name: "group", Commands: []*urfavecli.Command{{Name: "child"}}}) var output, diagnostic bytes.Buffer cliRoot.Writer = &output cliRoot.ErrWriter = &diagnostic code, err := Run(context.Background(), cliRoot, append([]string{"app"}, strings.Fields(tc.args)...)) if code != tc.code || (err != nil) != (tc.code != 0) { t.Fatalf("Run=%d,%v; want %d", code, err, tc.code) } if output.Len() == 0 && tc.code != 1 { t.Error("missing native help") } if (diagnostic.Len() > 0) != (tc.code != 0) { t.Fatalf("diagnostic %q", diagnostic.String()) } }) } } func TestNativeValidation(t *testing.T) { for _, kind := range []string{"environment", "validator", "validator-exit", "flag-action", "required-argument", "argument-parse", "exclusive"} { t.Run(kind, func(t *testing.T) { cliRoot := command(func(context.Context, *urfavecli.Command) error { t.Error("invalid invocation reached action") return nil }) cmd := cliRoot.Commands[0] args := []string{"app", "inspect"} want := 1 switch kind { case "environment": t.Setenv("CORESTACK_CLI_COUNT", "oops") cmd.Flags = []urfavecli.Flag{&urfavecli.IntFlag{Name: "count", Sources: urfavecli.EnvVars("CORESTACK_CLI_COUNT")}} case "validator", "validator-exit": cmd.ArgValidator = func(context.Context, *urfavecli.Command) error { if kind == "validator-exit" { return urfavecli.Exit("invalid", 2) } return errors.New("invalid") } if kind == "validator-exit" { want = 2 } case "flag-action": cmd.Flags = []urfavecli.Flag{&urfavecli.BoolFlag{Name: "bad", Action: func(context.Context, *urfavecli.Command, bool) error { return errors.New("invalid") }}} args = append(args, "--bad") case "required-argument": cmd.Arguments = []urfavecli.Argument{&urfavecli.StringArg{Name: "target", Required: true}} want = 2 case "argument-parse": cmd.Arguments = []urfavecli.Argument{&urfavecli.IntArg{Name: "count"}} args = append(args, "oops") want = 2 case "exclusive": cmd.MutuallyExclusiveFlags = []urfavecli.MutuallyExclusiveFlags{{Flags: [][]urfavecli.Flag{{&urfavecli.BoolFlag{Name: "a"}}, {&urfavecli.BoolFlag{Name: "b"}}}}} args = append(args, "--a", "--b") want = 2 } code, err := Run(context.Background(), cliRoot, args) if code != want || err == nil { t.Fatalf("Run=%d,%v; want %d", code, err, want) } }) } } func TestFailureCategoriesAndCauses(t *testing.T) { for _, kind := range []string{"native-exit2", "native-after", "native-exit0", "native-exit9", "action-exit2", "teardown", "setup", "action-after", "usage-after", "nested-after", "unknown-after"} { t.Run(kind, func(t *testing.T) { cause := &os.PathError{Op: "read", Path: "db", Err: io.ErrUnexpectedEOF} after := errors.New("after") parent := errors.New("parent after") original := urfavecli.Exit(cause, 2) cfg := config() modules := []lifecycle.Module{{Name: "db", Setup: func(context.Context) error { return nil }, Teardown: func(context.Context) error { return nil }}} action := func(context.Context, *urfavecli.Command) error { return original } want := 1 args := []string{"app", "inspect"} switch kind { case "native-exit2": want = 2 case "native-exit0": original = urfavecli.Exit(cause, 0) case "native-exit9": original = urfavecli.Exit(cause, 9) case "setup": modules[0].Setup = func(context.Context) error { return cause } case "teardown": modules[0].Teardown = func(context.Context) error { return after } } cliRoot := command(action) if !strings.HasPrefix(kind, "native-") { cliRoot.Commands[0].Action = Action(cfg, modules, action) } if kind == "native-after" || kind == "action-after" || kind == "nested-after" || kind == "usage-after" { cliRoot.Commands[0].After = func(context.Context, *urfavecli.Command) error { return after } } if kind == "nested-after" { cliRoot.After = func(context.Context, *urfavecli.Command) error { return parent } } if kind == "usage-after" { cliRoot.Commands[0].Flags = append(cliRoot.Commands[0].Flags, &urfavecli.StringFlag{Name: "needed", Required: true}) } if kind == "unknown-after" { args = []string{"app", "help", "missing"} cliRoot.After = func(context.Context, *urfavecli.Command) error { return after } } code, err := Run(context.Background(), cliRoot, args) if code != want || err == nil { t.Fatalf("Run=%d,%v", code, err) } if kind != "usage-after" && kind != "unknown-after" { var path *os.PathError if !errors.Is(err, cause) || !errors.As(err, &path) || path != cause || !errors.Is(err, io.ErrUnexpectedEOF) { t.Fatalf("cause lost: %v", err) } if kind != "setup" && !errors.Is(err, original) { t.Fatalf("original identity lost: %v", err) } } if kind == "teardown" || strings.HasSuffix(kind, "-after") { if !errors.Is(err, after) { t.Fatalf("After/cleanup lost: %v", err) } } if kind == "nested-after" && !errors.Is(err, parent) { t.Fatal("parent cause lost") } if kind == "native-after" || kind == "action-after" || kind == "nested-after" || kind == "usage-after" { var multi urfavecli.MultiError if !errors.As(err, &multi) || !errors.Is(err, multi) { t.Fatalf("native aggregate lost: %v", err) } if err.Error() != multi.Error() { t.Fatalf("duplicated formatting: %v", err) } } }) } } func TestValidationBeforeSetup(t *testing.T) { for _, kind := range []string{"nil-context", "nil-root", "nil-function", "nil-logger", "negative-timeout", "invalid-module", "duplicate-module", "nil-child", "exit-hook", "usage-hook", "not-found-hook"} { t.Run(kind, func(t *testing.T) { cfg := config() noop := func(context.Context) error { return nil } modules := []lifecycle.Module{{Name: "db", Setup: func(context.Context) error { t.Error("setup before validation"); return nil }, Teardown: noop}} var ctx context.Context = context.Background() fn := urfavecli.ActionFunc(func(context.Context, *urfavecli.Command) error { t.Error("action before validation"); return nil }) switch kind { case "nil-context": ctx = nil case "nil-function": fn = nil case "nil-logger": cfg.Logger = nil case "negative-timeout": cfg.ShutdownTimeout = -1 case "invalid-module": modules = append(modules, lifecycle.Module{Name: "invalid"}) case "duplicate-module": modules = append(modules, modules[0]) } cliRoot := command(Action(cfg, modules, fn)) switch kind { case "nil-root": cliRoot = nil case "nil-child": cliRoot.Commands = append(cliRoot.Commands, nil) case "exit-hook": cliRoot.Commands[0].ExitErrHandler = func(context.Context, *urfavecli.Command, error) {} case "usage-hook": cliRoot.Commands[0].OnUsageError = func(context.Context, *urfavecli.Command, error, bool) error { return nil } case "not-found-hook": cliRoot.Commands[0].CommandNotFound = func(context.Context, *urfavecli.Command, string) {} } code, err := Run(ctx, cliRoot, []string{"app", "inspect"}) if code != 1 || err == nil { t.Fatalf("Run=%d,%v", code, err) } }) } } func await[T any](t *testing.T, ch <-chan T) T { t.Helper() select { case value := <-ch: return value case <-time.After(3 * time.Second): t.Fatal("callback blocked") var zero T return zero } } func TestSignalsAndParent(t *testing.T) { for _, mode := range []string{"first", "second", "parent", "serve"} { t.Run(mode, func(t *testing.T) { signals := make(chan os.Signal, 2) cfg := config() cfg.Signals = signals ctx, cancel := context.WithCancel(context.Background()) defer cancel() ready := make(chan struct{}) canceled := make(chan struct{}) exited := make(chan struct{}) cleanup := make(chan struct{}) release := make(chan struct{}) defer func() { close(release); await(t, exited) }() callback := func(ctx context.Context) error { close(ready) defer close(exited) <-ctx.Done() close(canceled) if mode == "second" { <-release } return ctx.Err() } modules := []lifecycle.Module{{Name: "db", Setup: func(context.Context) error { return nil }, Teardown: func(context.Context) error { close(cleanup); return nil }, Run: callback}} action := Action(cfg, modules, func(ctx context.Context, _ *urfavecli.Command) error { return callback(ctx) }) if mode == "serve" { action = Serve(cfg, modules) } done := make(chan struct { code int err error }, 1) go func() { code, err := Run(ctx, command(action), []string{"app", "inspect"}) done <- struct { code int err error }{code, err} }() await(t, ready) if mode == "parent" { cancel() } else { signals <- syscall.SIGINT } await(t, canceled) await(t, cleanup) if mode == "second" { signals <- syscall.SIGTERM } result := await(t, done) want := 0 if mode == "second" || mode == "parent" { want = 1 } if result.code != want || (result.err != nil) != (want != 0) { t.Fatalf("Run=%d,%v", result.code, result.err) } if want == 1 && !errors.Is(result.err, context.Canceled) { t.Fatalf("cancellation lost: %v", result.err) } // The adapter must leave the injected channel open and reusable. signals <- syscall.SIGTERM }) } } func TestSharedShutdownDeadline(t *testing.T) { cfg := config() cfg.ShutdownTimeout = 100 * time.Millisecond ready := make(chan struct{}) shutdown := make(chan time.Time, 1) exited := make(chan struct{}) release := make(chan struct{}) defer func() { close(release); await(t, exited) }() var first time.Time noop := func(context.Context) error { return nil } modules := []lifecycle.Module{ {Name: "blocked", Setup: noop, Teardown: func(ctx context.Context) error { defer close(exited) deadline, _ := ctx.Deadline() shutdown <- deadline <-release return nil }}, {Name: "first", Setup: noop, Teardown: func(ctx context.Context) error { first, _ = ctx.Deadline() time.Sleep(20 * time.Millisecond) return nil }}, } done := make(chan error, 1) go func() { _, err := Run(context.Background(), command(Action(cfg, modules, func(context.Context, *urfavecli.Command) error { close(ready); return nil })), []string{"app", "inspect"}) done <- err }() await(t, ready) deadline := await(t, shutdown) if !first.Equal(deadline) { t.Fatal("shutdown deadline reset between hooks") } if err := await(t, done); !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("deadline lost: %v", err) } } func TestEmptySelectionsAndOwnedSignals(t *testing.T) { for _, oneOff := range []bool{false, true} { cfg := config() cfg.Signals = nil action := Serve(cfg, nil) if oneOff { action = Action(cfg, nil, func(context.Context, *urfavecli.Command) error { return nil }) } if code, err := Run(context.Background(), command(action), []string{"app", "inspect"}); code != 0 || err != nil { t.Fatalf("Run=%d,%v", code, err) } } cfg := config() signals := make(chan os.Signal) close(signals) cfg.Signals = signals if code, err := Run(context.Background(), command(Serve(cfg, nil)), []string{"app", "inspect"}); code != 0 || err != nil { t.Fatalf("closed signals: %d,%v", code, err) } } func TestPartialSetupAndMixedCleanup(t *testing.T) { for _, setupFailure := range []bool{false, true} { t.Run(fmt.Sprint(setupFailure), func(t *testing.T) { initiating := urfavecli.Exit(io.ErrUnexpectedEOF, 2) cleanup := errors.New("cleanup") var order []string modules := []lifecycle.Module{ {Name: "first", Setup: func(context.Context) error { order = append(order, "first setup"); return nil }, Teardown: func(context.Context) error { order = append(order, "first cleanup"); return cleanup }}, {Name: "second", Setup: func(context.Context) error { order = append(order, "second setup") if setupFailure { return initiating } return nil }, Teardown: func(context.Context) error { order = append(order, "second cleanup"); return nil }}, } cliRoot := command(Action(config(), modules, func(context.Context, *urfavecli.Command) error { order = append(order, "action"); return initiating })) code, err := Run(context.Background(), cliRoot, []string{"app", "inspect"}) if code != 1 || !errors.Is(err, initiating) || !errors.Is(err, cleanup) || !errors.Is(err, io.ErrUnexpectedEOF) { t.Fatalf("Run=%d,%v", code, err) } want := []string{"first setup", "second setup", "first cleanup"} if !setupFailure { want = []string{"first setup", "second setup", "action", "second cleanup", "first cleanup"} } if !reflect.DeepEqual(order, want) { t.Fatalf("order %v", order) } }) } }