Skip to content

Commit 1e9830e

Browse files
committed
test: add test coverage for io and runners
1 parent 7ee7ae7 commit 1e9830e

File tree

11 files changed

+877
-12
lines changed

11 files changed

+877
-12
lines changed

internal/io/common/paths_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package common_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/flowexec/flow/internal/io/common"
7+
)
8+
9+
func TestShortenPath(t *testing.T) {
10+
cases := []struct {
11+
name string
12+
input string
13+
want string
14+
}{
15+
{"empty", "", ""},
16+
{"single component", "foo", "foo"},
17+
{"two components", "foo/bar", "foo/bar"},
18+
{"three components", "a/b/c", "…/b/c"},
19+
{"deep unix path", "/Users/jahvon/workspaces/flow/internal/io", "…/internal/io"},
20+
{"trailing slash", "a/b/c/", "…/c/"},
21+
}
22+
for _, tc := range cases {
23+
t.Run(tc.name, func(t *testing.T) {
24+
if got := common.ShortenPath(tc.input); got != tc.want {
25+
t.Fatalf("ShortenPath(%q) = %q, want %q", tc.input, got, tc.want)
26+
}
27+
})
28+
}
29+
}

internal/io/common/tags_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package common_test
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
8+
"github.com/flowexec/flow/internal/io/common"
9+
)
10+
11+
func TestTagColor_Deterministic(t *testing.T) {
12+
a := common.TagColor("deployment")
13+
b := common.TagColor("deployment")
14+
if a != b {
15+
t.Fatalf("expected deterministic color for the same tag, got %v and %v", a, b)
16+
}
17+
}
18+
19+
func TestTagColor_DistinctTagsUsuallyDiffer(t *testing.T) {
20+
// Different tags typically produce different colors; collisions are possible
21+
// but extremely unlikely across a handful of common values.
22+
tags := []string{"a", "build", "deploy", "test", "release", "infra", "docs"}
23+
seen := make(map[string]int, len(tags))
24+
for _, t := range tags {
25+
seen[fmt.Sprintf("%v", common.TagColor(t))]++
26+
}
27+
if len(seen) < len(tags)-1 {
28+
t.Fatalf("expected nearly-unique colors across %d tags, got %d distinct", len(tags), len(seen))
29+
}
30+
}
31+
32+
func TestColorizeTags_EmptyReturnsEmpty(t *testing.T) {
33+
if got := common.ColorizeTags(nil); got != "" {
34+
t.Fatalf("expected empty string for nil tags, got %q", got)
35+
}
36+
if got := common.ColorizeTags([]string{}); got != "" {
37+
t.Fatalf("expected empty string for empty tags, got %q", got)
38+
}
39+
}
40+
41+
func TestColorizeTags_SortsAndJoinsWithComma(t *testing.T) {
42+
// Output contains ANSI styling, but sorted tag names should still appear in order,
43+
// separated by ", ".
44+
got := common.ColorizeTags([]string{"cherry", "apple", "banana"})
45+
iApple := strings.Index(got, "apple")
46+
iBanana := strings.Index(got, "banana")
47+
iCherry := strings.Index(got, "cherry")
48+
if iApple < 0 || iBanana < 0 || iCherry < 0 {
49+
t.Fatalf("expected all tag names in output, got %q", got)
50+
}
51+
if iApple >= iBanana || iBanana >= iCherry {
52+
t.Fatalf("expected alphabetical order apple < banana < cherry in %q", got)
53+
}
54+
if strings.Count(got, ", ") != 2 {
55+
t.Fatalf("expected 2 comma separators between 3 tags, got %q", got)
56+
}
57+
}
58+
59+
func TestColorizeTags_DoesNotMutateInput(t *testing.T) {
60+
input := []string{"zebra", "apple"}
61+
_ = common.ColorizeTags(input)
62+
if input[0] != "zebra" || input[1] != "apple" {
63+
t.Fatalf("ColorizeTags mutated input slice: %v", input)
64+
}
65+
}

internal/io/logs/records_test.go

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
package logs_test
2+
3+
import (
4+
"errors"
5+
"strings"
6+
"testing"
7+
"time"
8+
9+
"go.uber.org/mock/gomock"
10+
11+
"github.com/flowexec/flow/internal/io/logs"
12+
"github.com/flowexec/flow/pkg/store"
13+
storeMocks "github.com/flowexec/flow/pkg/store/mocks"
14+
)
15+
16+
func rec(ref string, exit int, at time.Time) logs.UnifiedRecord {
17+
return logs.UnifiedRecord{ExecutionRecord: store.ExecutionRecord{Ref: ref, ExitCode: exit, StartedAt: at}}
18+
}
19+
20+
func TestFilterRecords_EmptyFilterReturnsAll(t *testing.T) {
21+
now := time.Now()
22+
records := []logs.UnifiedRecord{
23+
rec("exec a/ns:one", 0, now),
24+
rec("exec b/ns:two", 1, now),
25+
}
26+
got := logs.FilterRecords(records, logs.RecordFilter{})
27+
if len(got) != 2 {
28+
t.Fatalf("expected all 2 records with empty filter, got %d", len(got))
29+
}
30+
}
31+
32+
func TestFilterRecords_Workspace(t *testing.T) {
33+
records := []logs.UnifiedRecord{
34+
rec("exec alpha/ns:a", 0, time.Now()),
35+
rec("exec beta/ns:b", 0, time.Now()),
36+
rec("exec alpha/ns:c", 0, time.Now()),
37+
}
38+
got := logs.FilterRecords(records, logs.RecordFilter{Workspace: "alpha"})
39+
if len(got) != 2 {
40+
t.Fatalf("expected 2 alpha records, got %d", len(got))
41+
}
42+
for _, r := range got {
43+
if !strings.Contains(r.Ref, " alpha/") {
44+
t.Fatalf("unexpected workspace in filtered result: %q", r.Ref)
45+
}
46+
}
47+
}
48+
49+
func TestFilterRecords_Status(t *testing.T) {
50+
records := []logs.UnifiedRecord{
51+
rec("exec a/ns:ok", 0, time.Now()),
52+
rec("exec a/ns:bad", 1, time.Now()),
53+
rec("exec a/ns:ok2", 0, time.Now()),
54+
}
55+
successes := logs.FilterRecords(records, logs.RecordFilter{Status: "success"})
56+
if len(successes) != 2 {
57+
t.Fatalf("expected 2 success records, got %d", len(successes))
58+
}
59+
failures := logs.FilterRecords(records, logs.RecordFilter{Status: "failure"})
60+
if len(failures) != 1 {
61+
t.Fatalf("expected 1 failure record, got %d", len(failures))
62+
}
63+
}
64+
65+
func TestFilterRecords_Since(t *testing.T) {
66+
old := time.Now().Add(-2 * time.Hour)
67+
mid := time.Now().Add(-30 * time.Minute)
68+
recent := time.Now()
69+
records := []logs.UnifiedRecord{
70+
rec("exec a/ns:old", 0, old),
71+
rec("exec a/ns:mid", 0, mid),
72+
rec("exec a/ns:new", 0, recent),
73+
}
74+
since := time.Now().Add(-1 * time.Hour)
75+
got := logs.FilterRecords(records, logs.RecordFilter{Since: since})
76+
if len(got) != 2 {
77+
t.Fatalf("expected 2 records after %v, got %d", since, len(got))
78+
}
79+
}
80+
81+
func TestFilterRecords_Limit(t *testing.T) {
82+
now := time.Now()
83+
records := []logs.UnifiedRecord{
84+
rec("exec a/ns:1", 0, now),
85+
rec("exec a/ns:2", 0, now),
86+
rec("exec a/ns:3", 0, now),
87+
rec("exec a/ns:4", 0, now),
88+
}
89+
got := logs.FilterRecords(records, logs.RecordFilter{Limit: 2})
90+
if len(got) != 2 {
91+
t.Fatalf("expected limit to cap at 2, got %d", len(got))
92+
}
93+
94+
// Limit greater than length should be a no-op.
95+
got = logs.FilterRecords(records, logs.RecordFilter{Limit: 99})
96+
if len(got) != 4 {
97+
t.Fatalf("expected all 4 records when limit > len, got %d", len(got))
98+
}
99+
}
100+
101+
func TestFilterRecords_Combined(t *testing.T) {
102+
now := time.Now()
103+
past := now.Add(-24 * time.Hour)
104+
records := []logs.UnifiedRecord{
105+
rec("exec alpha/ns:old-ok", 0, past),
106+
rec("exec alpha/ns:new-ok", 0, now),
107+
rec("exec alpha/ns:new-fail", 1, now),
108+
rec("exec beta/ns:new-ok", 0, now),
109+
}
110+
got := logs.FilterRecords(records, logs.RecordFilter{
111+
Workspace: "alpha",
112+
Status: "success",
113+
Since: now.Add(-1 * time.Hour),
114+
})
115+
if len(got) != 1 {
116+
t.Fatalf("expected exactly 1 record matching all filters, got %d", len(got))
117+
}
118+
if got[0].Ref != "exec alpha/ns:new-ok" {
119+
t.Fatalf("unexpected record: %q", got[0].Ref)
120+
}
121+
}
122+
123+
func TestLoadRecords_SortsResultsByStartedAtDescending(t *testing.T) {
124+
ctrl := gomock.NewController(t)
125+
ds := storeMocks.NewMockDataStore(ctrl)
126+
t1 := time.Now().Add(-2 * time.Hour)
127+
t2 := time.Now().Add(-1 * time.Hour)
128+
t3 := time.Now()
129+
130+
ds.EXPECT().ListExecutionRefs().Return([]string{"one"}, nil)
131+
ds.EXPECT().GetExecutionHistory("one", 10).Return([]store.ExecutionRecord{
132+
{Ref: "a", StartedAt: t1},
133+
{Ref: "b", StartedAt: t3},
134+
{Ref: "c", StartedAt: t2},
135+
}, nil)
136+
137+
got, err := logs.LoadRecords(ds, "")
138+
if err != nil {
139+
t.Fatalf("unexpected error: %v", err)
140+
}
141+
if len(got) != 3 {
142+
t.Fatalf("expected 3 records, got %d", len(got))
143+
}
144+
if got[0].Ref != "b" || got[1].Ref != "c" || got[2].Ref != "a" {
145+
t.Fatalf("expected descending StartedAt order b,c,a; got %s,%s,%s", got[0].Ref, got[1].Ref, got[2].Ref)
146+
}
147+
if got[0].LogEntry != nil {
148+
t.Fatalf("expected LogEntry to be nil when logsDir is empty")
149+
}
150+
}
151+
152+
func TestLoadRecords_NilDataStoreReturnsEmpty(t *testing.T) {
153+
got, err := logs.LoadRecords(nil, "")
154+
if err != nil {
155+
t.Fatalf("unexpected error: %v", err)
156+
}
157+
if got != nil {
158+
t.Fatalf("expected nil records for nil data store, got %d", len(got))
159+
}
160+
}
161+
162+
func TestLoadRecordsForRef_NilDataStoreReturnsEmpty(t *testing.T) {
163+
got, err := logs.LoadRecordsForRef(nil, "", "ref", 10)
164+
if err != nil {
165+
t.Fatalf("unexpected error: %v", err)
166+
}
167+
if got != nil {
168+
t.Fatalf("expected nil records for nil data store, got %d", len(got))
169+
}
170+
}
171+
172+
func TestLoadRecords_PropagatesListRefsError(t *testing.T) {
173+
ctrl := gomock.NewController(t)
174+
ds := storeMocks.NewMockDataStore(ctrl)
175+
wantErr := errors.New("boom")
176+
ds.EXPECT().ListExecutionRefs().Return(nil, wantErr)
177+
178+
_, err := logs.LoadRecords(ds, "")
179+
if !errors.Is(err, wantErr) {
180+
t.Fatalf("expected error %v, got %v", wantErr, err)
181+
}
182+
}
183+
184+
func TestLoadRecords_SkipsRefsThatErrorDuringHistoryLookup(t *testing.T) {
185+
ctrl := gomock.NewController(t)
186+
ds := storeMocks.NewMockDataStore(ctrl)
187+
now := time.Now()
188+
189+
ds.EXPECT().ListExecutionRefs().Return([]string{"good", "bad"}, nil)
190+
ds.EXPECT().GetExecutionHistory("good", 10).Return([]store.ExecutionRecord{
191+
{Ref: "good", StartedAt: now},
192+
}, nil)
193+
ds.EXPECT().GetExecutionHistory("bad", 10).Return(nil, errors.New("history error"))
194+
195+
got, err := logs.LoadRecords(ds, "")
196+
if err != nil {
197+
t.Fatalf("unexpected error: %v", err)
198+
}
199+
if len(got) != 1 || got[0].Ref != "good" {
200+
t.Fatalf("expected only the 'good' record to survive; got %+v", got)
201+
}
202+
}
203+
204+
func TestLoadRecordsForRef_UsesGivenRefAndLimit(t *testing.T) {
205+
ctrl := gomock.NewController(t)
206+
ds := storeMocks.NewMockDataStore(ctrl)
207+
now := time.Now()
208+
209+
ds.EXPECT().GetExecutionHistory("my-ref", 5).Return([]store.ExecutionRecord{
210+
{Ref: "my-ref", StartedAt: now},
211+
}, nil)
212+
213+
got, err := logs.LoadRecordsForRef(ds, "", "my-ref", 5)
214+
if err != nil {
215+
t.Fatalf("unexpected error: %v", err)
216+
}
217+
if len(got) != 1 || got[0].Ref != "my-ref" {
218+
t.Fatalf("unexpected records: %+v", got)
219+
}
220+
}

internal/runner/exec/exec.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ import (
1212
"github.com/flowexec/flow/types/executable"
1313
)
1414

15+
// Test seams: swapped by tests to avoid spawning real subshells.
16+
var (
17+
runCmdFn = run.RunCmd
18+
runFileFn = run.RunFile
19+
)
20+
1521
type execRunner struct{}
1622

1723
func NewRunner() runner.Runner {
@@ -79,9 +85,9 @@ func (r *execRunner) Exec(
7985
case execSpec.Cmd != "" && execSpec.File != "":
8086
return errors.New("cannot set both cmd and file")
8187
case execSpec.Cmd != "":
82-
return run.RunCmd(execSpec.Cmd, targetDir, envList, logMode, logger.Log(), ctx.StdIn(), logFields, ctx.CurrentTask)
88+
return runCmdFn(execSpec.Cmd, targetDir, envList, logMode, logger.Log(), ctx.StdIn(), logFields, ctx.CurrentTask)
8389
case execSpec.File != "":
84-
return run.RunFile(execSpec.File, targetDir, envList, logMode, logger.Log(), ctx.StdIn(), logFields, ctx.CurrentTask)
90+
return runFileFn(execSpec.File, targetDir, envList, logMode, logger.Log(), ctx.StdIn(), logFields, ctx.CurrentTask)
8591
default:
8692
return errors.New("unable to determine how e should be run")
8793
}

0 commit comments

Comments
 (0)