Skip to content

Commit 7ee7ae7

Browse files
committed
test: add test coverage for fatal cmd executions
1 parent 93afc60 commit 7ee7ae7

File tree

11 files changed

+233
-30
lines changed

11 files changed

+233
-30
lines changed

internal/utils/env/args.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ func parseArgs(args executable.ArgumentList, execArgs []string) (flagArgs map[st
5555
flagArgs[flagStr] = execArgs[i]
5656
}
5757
}
58-
return
58+
return flagArgs, posArgs
5959
}
6060

6161
func resolveArgValues(

tests/browse_cmds_e2e_test.go

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,6 @@ var _ = Describe("browse TUI", func() {
6767
out, err := stdIO.ReadAll(tm.FinalOutput(GinkgoTB()))
6868
Expect(err).NotTo(HaveOccurred())
6969
Expect(out).NotTo(BeEmpty())
70-
71-
// TODO: fix golden generation / normalization / comparison
72-
// utils.MaybeUpdateGolden(GinkgoTB(), out)
73-
// utils.RequireEqualSnapshot(GinkgoTB(), out)
7470
})
7571

7672
Specify("wide snapshot", func() {
@@ -95,10 +91,6 @@ var _ = Describe("browse TUI", func() {
9591
out, err := stdIO.ReadAll(tm.FinalOutput(GinkgoTB()))
9692
Expect(err).NotTo(HaveOccurred())
9793
Expect(out).NotTo(BeEmpty())
98-
99-
// TODO: fix golden generation / normalization / comparison
100-
// utils.MaybeUpdateGolden(GinkgoTB(), out)
101-
// utils.RequireEqualSnapshot(GinkgoTB(), out)
10294
})
10395

10496
Specify("list snapshot", func() {
@@ -117,10 +109,6 @@ var _ = Describe("browse TUI", func() {
117109
out, err := stdIO.ReadAll(tm.FinalOutput(GinkgoTB()))
118110
Expect(err).NotTo(HaveOccurred())
119111
Expect(out).NotTo(BeEmpty())
120-
121-
// TODO: fix golden generation / normalization / comparison
122-
// utils.MaybeUpdateGolden(GinkgoTB(), out)
123-
// utils.RequireEqualSnapshot(GinkgoTB(), out)
124112
})
125113

126114
Specify("exec snapshot", func() {
@@ -144,10 +132,6 @@ var _ = Describe("browse TUI", func() {
144132
out, err := stdIO.ReadAll(tm.FinalOutput(GinkgoTB()))
145133
Expect(err).NotTo(HaveOccurred())
146134
Expect(out).NotTo(BeEmpty())
147-
148-
// TODO: fix golden generation / normalization / comparison
149-
// utils.MaybeUpdateGolden(GinkgoTB(), out)
150-
// utils.RequireEqualSnapshot(GinkgoTB(), out)
151135
})
152136
})
153137

@@ -196,4 +180,22 @@ var _ = Describe("browse e2e", Ordered, func() {
196180
Expect(err).NotTo(HaveOccurred())
197181
Expect(out).To(ContainSubstring("name: simple-print"))
198182
})
183+
184+
When("browsing with an invalid visibility filter", func() {
185+
It("fatals with an invalid visibility message", func() {
186+
ctx.ExpectFailure()
187+
err := run.Run(ctx.Context, "browse", "--list", "--visibility", "bogus")
188+
Expect(err).To(HaveOccurred())
189+
Expect(ctx.ExitCalls()).To(ContainElement(ContainSubstring("invalid visibility")))
190+
})
191+
})
192+
193+
When("browsing an executable that does not exist", func() {
194+
It("fatals with a not-found message", func() {
195+
ctx.ExpectFailure()
196+
err := run.Run(ctx.Context, "browse", "exec", "examples:doesnotexist")
197+
Expect(err).To(HaveOccurred())
198+
Expect(ctx.ExitCalls()).NotTo(BeEmpty())
199+
})
200+
})
199201
})

tests/config_cmds_e2e_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,15 @@ var _ = Describe("config e2e", Ordered, func() {
167167
})
168168
})
169169

170+
When("setting an invalid timeout (flow config set timeout bogus)", func() {
171+
It("fatals with an invalid duration message", func() {
172+
ctx.ExpectFailure()
173+
err := run.Run(ctx.Context, "config", "set", "timeout", "bogus")
174+
Expect(err).To(HaveOccurred())
175+
Expect(ctx.ExitCalls()).To(ContainElement(ContainSubstring("invalid duration")))
176+
})
177+
})
178+
170179
When("resetting configuration (flow config reset)", func() {
171180
It("should prompt for confirmation and reset config", func() {
172181
reader, writer, err := os.Pipe()

tests/exec_cmd_e2e_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ var _ = Describe("exec e2e", func() {
2626
ctx.Finalize()
2727
})
2828

29+
When("executing a nonexistent executable (flow exec)", func() {
30+
It("fatals with a not-found message", func() {
31+
runner := utils.NewE2ECommandRunner()
32+
ctx.ExpectFailure()
33+
err := runner.Run(ctx.Context, "exec", "examples:doesnotexist")
34+
Expect(err).To(HaveOccurred())
35+
Expect(ctx.ExitCalls()).NotTo(BeEmpty())
36+
})
37+
})
38+
2939
DescribeTable("with dir example executables", func(ref string) {
3040
runner := utils.NewE2ECommandRunner()
3141
stdOut := ctx.StdOut()

tests/git_workspace_e2e_test.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,40 @@ var _ = Describe("git workspace e2e", Ordered, func() {
106106
})
107107
})
108108

109-
// Note: Negative test cases (e.g. updating a non-git workspace, conflicting flags)
110-
// use logger.Fatalf which calls tb.Fatalf in the test context. Since tb.Fatalf
111-
// invokes runtime.Goexit, these cannot be caught as errors by the CommandRunner.
109+
When("adding a workspace with conflicting git flags (flow workspace add)", func() {
110+
It("fatals when both --branch and --tag are specified", func() {
111+
ctx.ExpectFailure()
112+
err := run.Run(
113+
ctx.Context, "workspace", "add", "conflict-ws", bareRepoURL,
114+
"--branch", "main", "--tag", "v1",
115+
)
116+
Expect(err).To(HaveOccurred())
117+
Expect(ctx.ExitCalls()).To(ContainElement(ContainSubstring("cannot specify both --branch and --tag")))
118+
})
119+
120+
It("fatals when adding a workspace that already exists", func() {
121+
ctx.ExpectFailure()
122+
err := run.Run(ctx.Context, "workspace", "add", wsName, bareRepoURL)
123+
Expect(err).To(HaveOccurred())
124+
Expect(ctx.ExitCalls()).To(ContainElement(ContainSubstring("already exists")))
125+
})
126+
})
127+
128+
When("updating a non-git workspace (flow workspace update)", func() {
129+
It("fatals with a not-a-git-workspace message", func() {
130+
ctx.ExpectFailure()
131+
err := run.Run(ctx.Context, "workspace", "update", utils.TestWorkspaceName)
132+
Expect(err).To(HaveOccurred())
133+
Expect(ctx.ExitCalls()).To(ContainElement(ContainSubstring("is not a git-sourced workspace")))
134+
})
135+
136+
It("fatals when updating a workspace that does not exist", func() {
137+
ctx.ExpectFailure()
138+
err := run.Run(ctx.Context, "workspace", "update", "doesnotexist")
139+
Expect(err).To(HaveOccurred())
140+
Expect(ctx.ExitCalls()).To(ContainElement(ContainSubstring("workspace doesnotexist not found")))
141+
})
142+
})
112143
})
113144

114145
// initBareRepo creates a local bare git repo with a flow.yaml file,

tests/logs_cmds_e2e_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,40 @@ var _ = Describe("logs e2e", Ordered, func() {
151151
})
152152
})
153153

154+
When("viewing the last log with no history (flow logs --last)", func() {
155+
It("fatals with a no-history message", func() {
156+
// Clear any records from earlier specs in this Ordered suite.
157+
refs, err := ctx.DataStore.ListExecutionRefs()
158+
Expect(err).NotTo(HaveOccurred())
159+
for _, ref := range refs {
160+
_ = ctx.DataStore.DeleteExecutionHistory(ref)
161+
}
162+
163+
ctx.ExpectFailure()
164+
err = run.Run(ctx.Context, "logs", "--last")
165+
Expect(err).To(HaveOccurred())
166+
Expect(ctx.ExitCalls()).To(ContainElement(ContainSubstring("No execution history found")))
167+
})
168+
})
169+
170+
When("filtering logs with an invalid duration (flow logs --since bogus)", func() {
171+
It("fatals with an invalid --since message", func() {
172+
ctx.ExpectFailure()
173+
err := run.Run(ctx.Context, "logs", "--since", "bogus")
174+
Expect(err).To(HaveOccurred())
175+
Expect(ctx.ExitCalls()).To(ContainElement(ContainSubstring("Invalid --since value")))
176+
})
177+
})
178+
179+
When("attaching to a background run that does not exist (flow logs attach)", func() {
180+
It("fatals with a not-found message", func() {
181+
ctx.ExpectFailure()
182+
err := run.Run(ctx.Context, "logs", "attach", "doesnotexist")
183+
Expect(err).To(HaveOccurred())
184+
Expect(ctx.ExitCalls()).To(ContainElement(ContainSubstring("not found")))
185+
})
186+
})
187+
154188
When("attaching to a background process (flow logs attach)", func() {
155189
It("should display log content from the archive file", func() {
156190
// Create a temporary log archive file with known content.

tests/secret_cmds_e2e_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,24 @@ var _ = Describe("vault/secrets e2e", Ordered, func() {
9292
})
9393
})
9494

95+
When("getting a vault that does not exist (flow vault get doesnotexist)", func() {
96+
It("fatals with a failure to get vault message", func() {
97+
ctx.ExpectFailure()
98+
err := run.Run(ctx.Context, "vault", "get", "doesnotexist")
99+
Expect(err).To(HaveOccurred())
100+
Expect(ctx.ExitCalls()).To(ContainElement(ContainSubstring("Failed to get vault doesnotexist")))
101+
})
102+
})
103+
104+
When("setting a secret with both --file and a value (flow secret set)", func() {
105+
It("fatals with a mutually exclusive input error", func() {
106+
ctx.ExpectFailure()
107+
err := run.Run(ctx.Context, "secret", "set", "message", "inline-value", "--file", "doesnotexist")
108+
Expect(err).To(HaveOccurred())
109+
Expect(ctx.ExitCalls()).To(ContainElement(ContainSubstring("either a filename OR a value")))
110+
})
111+
})
112+
95113
When("listing vaults (flow vault list)", func() {
96114
It("should list vaults in YAML format", func() {
97115
stdOut := ctx.StdOut()

tests/template_cmds_e2e_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,15 @@ var _ = Describe("flowfile template commands e2e", Ordered, func() {
151151
})
152152
})
153153

154+
When("getting a template that is not registered (flow template get)", func() {
155+
It("fatals with an unable-to-load message", func() {
156+
ctx.ExpectFailure()
157+
err := run.Run(ctx.Context, "template", "get", "-t", "doesnotexist", "-o", "yaml")
158+
Expect(err).To(HaveOccurred())
159+
Expect(ctx.ExitCalls()).To(ContainElement(ContainSubstring("unable to load flowfile template")))
160+
})
161+
})
162+
154163
When("Rendering a template (flow template generate)", func() {
155164
It("should process the template options and render the flowfile", func() {
156165
name := "test"

tests/utils/context.go

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77
"path/filepath"
88
"strings"
9+
"sync"
910
"testing"
1011

1112
tuikitIO "github.com/flowexec/tuikit/io"
@@ -39,26 +40,96 @@ type Context struct {
3940
cacheDir string
4041
configDir string
4142
wsDir string
43+
exit *ExitRecorder
4244
}
4345

4446
func (c *Context) WorkspaceDir() string {
4547
return c.wsDir
4648
}
4749

50+
// ExpectFailure flips the test context into capture mode: subsequent fatal
51+
// logger calls will be recorded on the context and cause the currently
52+
// executing command to return an error instead of failing the test. Reset
53+
// on every ResetTestContext call.
54+
func (c *Context) ExpectFailure() {
55+
c.exit.setExpect(true)
56+
}
57+
58+
// ExitCalls returns the fatal messages captured while the context was in
59+
// failure-capture mode.
60+
func (c *Context) ExitCalls() []string {
61+
return c.exit.snapshot()
62+
}
63+
64+
// ExitRecorder routes logger fatal calls so they can be asserted on in tests
65+
// rather than aborting the process or the test goroutine.
66+
type ExitRecorder struct {
67+
mu sync.Mutex
68+
expect bool
69+
calls []string
70+
tb testing.TB
71+
}
72+
73+
// fatalExit is the panic value emitted by the recorder when capture mode is on.
74+
// CommandRunner.Run recovers from it and surfaces the message as an error.
75+
type fatalExit struct{ msg string }
76+
77+
func (f fatalExit) String() string { return f.msg }
78+
79+
func newExitRecorder(tb testing.TB) *ExitRecorder {
80+
return &ExitRecorder{tb: tb}
81+
}
82+
83+
func (r *ExitRecorder) setTB(tb testing.TB) {
84+
r.mu.Lock()
85+
defer r.mu.Unlock()
86+
r.tb = tb
87+
r.expect = false
88+
r.calls = nil
89+
}
90+
91+
func (r *ExitRecorder) setExpect(v bool) {
92+
r.mu.Lock()
93+
defer r.mu.Unlock()
94+
r.expect = v
95+
if v {
96+
r.calls = nil
97+
}
98+
}
99+
100+
func (r *ExitRecorder) snapshot() []string {
101+
r.mu.Lock()
102+
defer r.mu.Unlock()
103+
return append([]string(nil), r.calls...)
104+
}
105+
106+
func (r *ExitRecorder) exit(msg string, args ...any) {
107+
formatted := fmt.Sprintf(msg, args...)
108+
r.mu.Lock()
109+
expect := r.expect
110+
if expect {
111+
r.calls = append(r.calls, formatted)
112+
}
113+
tb := r.tb
114+
r.mu.Unlock()
115+
if expect {
116+
panic(fatalExit{msg: formatted})
117+
}
118+
tb.Fatalf("logger exit called - %s", formatted)
119+
}
120+
48121
// NewContext creates a new context for testing runners. It initializes the context with
49122
// a real logger that writes it's output to a temporary file.
50123
// It also creates a temporary testing directory for the test workspace, user configs, and caches.
51124
// Test environment variables are set the config and cache directories override paths.
52125
func NewContext(ctx stdCtx.Context, tb testing.TB) *Context {
53126
stdOut, stdIn := createTempIOFiles(tb)
127+
recorder := newExitRecorder(tb)
54128
tempLogger := tuikitIO.NewLogger(
55129
tuikitIO.WithOutput(stdOut),
56130
tuikitIO.WithTheme(logger.Theme("")),
57131
tuikitIO.WithMode(tuikitIO.Text),
58-
tuikitIO.WithExitFunc(func(msg string, args ...any) {
59-
msg = fmt.Sprintf(msg, args...)
60-
tb.Fatalf("logger exit called - %s", msg)
61-
}),
132+
tuikitIO.WithExitFunc(recorder.exit),
62133
)
63134
logger.Init(logger.InitOptions{Logger: tempLogger, TestingTB: tb})
64135
ctxx, configDir, cacheDir, wsDir := newTestContext(ctx, tb, stdIn, stdOut)
@@ -67,6 +138,7 @@ func NewContext(ctx stdCtx.Context, tb testing.TB) *Context {
67138
configDir: configDir,
68139
cacheDir: cacheDir,
69140
wsDir: wsDir,
141+
exit: recorder,
70142
}
71143
}
72144

@@ -126,14 +198,12 @@ func ResetTestContext(ctx *Context, tb testing.TB) {
126198
stdIn, stdOut := createTempIOFiles(tb)
127199
ctx.SetIO(stdIn, stdOut)
128200
setTestEnv(tb, ctx.configDir, ctx.cacheDir)
201+
ctx.exit.setTB(tb)
129202
newLogger := tuikitIO.NewLogger(
130203
tuikitIO.WithOutput(stdOut),
131204
tuikitIO.WithTheme(logger.Theme("")),
132205
tuikitIO.WithMode(tuikitIO.Text),
133-
tuikitIO.WithExitFunc(func(msg string, args ...any) {
134-
msg = fmt.Sprintf(msg, args...)
135-
tb.Fatalf("logger exit called - %s", msg)
136-
}),
206+
tuikitIO.WithExitFunc(ctx.exit.exit),
137207
)
138208
logger.Init(logger.InitOptions{Logger: newLogger, TestingTB: tb})
139209
}

tests/utils/runner.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,12 @@ func NewE2ECommandRunner() *CommandRunner {
1818

1919
func (r *CommandRunner) Run(ctx *context.Context, args ...string) (err error) {
2020
defer func() {
21-
if r := recover(); r != nil {
22-
err = fmt.Errorf("panic occurred: %v", r)
21+
if rec := recover(); rec != nil {
22+
if fe, ok := rec.(fatalExit); ok {
23+
err = fmt.Errorf("fatal exit: %s", fe.msg)
24+
return
25+
}
26+
err = fmt.Errorf("panic occurred: %v", rec)
2327
}
2428
}()
2529
rootCmd := cli.BuildRootCommand(ctx)

0 commit comments

Comments
 (0)