detached 89110a8

test(daemon): cover bar script command runner boundary

Signed-off-by: brice <b@bnema.dev>
commit
89110a82514c3e82f3d24188bd1e25f728f200a1
author
brice · · 2026-07-06 16:40 UTC
parent
9182b6b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +70 -4
M internal/adapters/shellcmd/runner_test.go +34 -0
@@ -19,6 +19,9 @@ func TestRunnerRun(t *testing.T) {
1919 }{
2020 {name: "success", command: "printf 'ok'", limit: 1024, want: "ok"},
2121 {name: "bounded stdout", command: "yes a | head -c 4096", limit: 8, want: "a\na\na\na\n"},
22+ {name: "stderr discarded", command: "printf out; printf err >&2", limit: 1024, want: "out"},
23+ {name: "zero limit captures nothing", command: "printf output", limit: 0, want: ""},
24+ {name: "negative limit captures nothing", command: "printf output", limit: -1, want: ""},
2225 }
2326 for _, tt := range tests {
2427 t.Run(tt.name, func(t *testing.T) {
@@ -36,6 +39,37 @@ func TestRunnerRun(t *testing.T) {
3639 }
3740 }
3841
42+func TestRunnerRunReturnsPartialStdoutOnError(t *testing.T) {
43+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
44+ defer cancel()
45+
46+ got, err := New().Run(ctx, ports.CommandSpec{Command: "printf 'abcdef'; exit 7", Timeout: time.Second, StdoutLimit: 3})
47+ if err == nil {
48+ t.Fatal("Run() error = nil, want non-zero exit error")
49+ }
50+ if string(got) != "abc" {
51+ t.Fatalf("Run() = %q, want bounded partial stdout %q", string(got), "abc")
52+ }
53+}
54+
55+func TestRunnerRunTimeoutReturnsPromptly(t *testing.T) {
56+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
57+ defer cancel()
58+ start := time.Now()
59+
60+ _, err := New().Run(ctx, ports.CommandSpec{Command: "sleep 1", Timeout: 50 * time.Millisecond, StdoutLimit: 1024})
61+
62+ if err == nil {
63+ t.Fatal("Run() error = nil, want timeout or wait error")
64+ }
65+ if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
66+ t.Fatalf("context error = %v, want DeadlineExceeded", ctx.Err())
67+ }
68+ if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
69+ t.Fatalf("Run() elapsed = %s, want <= 500ms", elapsed)
70+ }
71+}
72+
3973 func TestRunnerRunBackgroundChildDoesNotWedgeAfterTimeout(t *testing.T) {
4074 ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
4175 defer cancel()
@@ -19,6 +19,9 @@ func TestRunnerRun(t *testing.T) {
19 }{19 }{
20 {name: "success", command: "printf 'ok'", limit: 1024, want: "ok"},20 {name: "success", command: "printf 'ok'", limit: 1024, want: "ok"},
21 {name: "bounded stdout", command: "yes a | head -c 4096", limit: 8, want: "a\na\na\na\n"},21 {name: "bounded stdout", command: "yes a | head -c 4096", limit: 8, want: "a\na\na\na\n"},
22+ {name: "stderr discarded", command: "printf out; printf err >&2", limit: 1024, want: "out"},
23+ {name: "zero limit captures nothing", command: "printf output", limit: 0, want: ""},
24+ {name: "negative limit captures nothing", command: "printf output", limit: -1, want: ""},
22 }25 }
23 for _, tt := range tests {26 for _, tt := range tests {
24 t.Run(tt.name, func(t *testing.T) {27 t.Run(tt.name, func(t *testing.T) {
@@ -36,6 +39,37 @@ func TestRunnerRun(t *testing.T) {
36 }39 }
37 }40 }
38 41
42+func TestRunnerRunReturnsPartialStdoutOnError(t *testing.T) {
43+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
44+ defer cancel()
45+
46+ got, err := New().Run(ctx, ports.CommandSpec{Command: "printf 'abcdef'; exit 7", Timeout: time.Second, StdoutLimit: 3})
47+ if err == nil {
48+ t.Fatal("Run() error = nil, want non-zero exit error")
49+ }
50+ if string(got) != "abc" {
51+ t.Fatalf("Run() = %q, want bounded partial stdout %q", string(got), "abc")
52+ }
53+}
54+
55+func TestRunnerRunTimeoutReturnsPromptly(t *testing.T) {
56+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
57+ defer cancel()
58+ start := time.Now()
59+
60+ _, err := New().Run(ctx, ports.CommandSpec{Command: "sleep 1", Timeout: 50 * time.Millisecond, StdoutLimit: 1024})
61+
62+ if err == nil {
63+ t.Fatal("Run() error = nil, want timeout or wait error")
64+ }
65+ if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
66+ t.Fatalf("context error = %v, want DeadlineExceeded", ctx.Err())
67+ }
68+ if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
69+ t.Fatalf("Run() elapsed = %s, want <= 500ms", elapsed)
70+ }
71+}
72+
39 func TestRunnerRunBackgroundChildDoesNotWedgeAfterTimeout(t *testing.T) {73 func TestRunnerRunBackgroundChildDoesNotWedgeAfterTimeout(t *testing.T) {
40 ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)74 ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
41 defer cancel()75 defer cancel()
M internal/usecase/daemon/bar_script_test.go +36 -4
@@ -83,9 +83,20 @@ func TestBarScriptContextEnv(t *testing.T) {
8383 }
8484
8585 func TestBarScriptRunner(t *testing.T) {
86+ t.Run("nil runner returns explicit error for command", func(t *testing.T) {
87+ runner := barScriptRunner{}
88+ got, err := runner.run(context.Background(), "date", barScriptContext{})
89+ if err == nil || err.Error() != "bar script command runner is nil" {
90+ t.Fatalf("run err = %v, want explicit nil runner error", err)
91+ }
92+ if got != "" {
93+ t.Fatalf("run output = %q, want empty", got)
94+ }
95+ })
96+
8697 t.Run("empty command skips port runner", func(t *testing.T) {
8798 runner := barScriptRunner{}
88- got, err := runner.run(context.Background(), "", barScriptContext{})
99+ got, err := runner.run(context.Background(), " \t\n ", barScriptContext{})
89100 if err != nil {
90101 t.Fatalf("run err = %v, want nil", err)
91102 }
@@ -100,12 +111,18 @@ func TestBarScriptRunner(t *testing.T) {
100111 return spec.Command == "printf 'ok\\nignored'" &&
101112 spec.Timeout == 50*time.Millisecond &&
102113 spec.StdoutLimit == barScriptOutputLimit &&
114+ containsEnv(spec.Env, "PATH=/bin") &&
103115 containsEnv(spec.Env, "VEV_ANCHOR=top-right") &&
104- containsEnv(spec.Env, "VEV_COLS=120")
105- })).Return([]byte("ok\nignored"), nil)
116+ containsEnv(spec.Env, "VEV_SESSION=work") &&
117+ containsEnv(spec.Env, "VEV_TAB=tab-1") &&
118+ containsEnv(spec.Env, "VEV_PANE=pane-1") &&
119+ containsEnv(spec.Env, "VEV_PANE_CWD=/repo") &&
120+ containsEnv(spec.Env, "VEV_COLS=120") &&
121+ !containsEnv(spec.Env, "VEV_ANCHOR=old")
122+ })).Return([]byte("\x1b[31mok\x1b[0m\nignored"), nil)
106123 runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond, baseEnv: []string{"PATH=/bin", "VEV_ANCHOR=old"}}
107124
108- got, err := runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{Anchor: "top-right", Cols: 120})
125+ got, err := runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{Anchor: "top-right", Session: "work", Tab: "tab-1", Pane: "pane-1", PaneCWD: "/repo", Cols: 120})
109126 if err != nil {
110127 t.Fatalf("run err = %v, want nil", err)
111128 }
@@ -114,6 +131,21 @@ func TestBarScriptRunner(t *testing.T) {
114131 }
115132 })
116133
134+ t.Run("port failure returns sanitized partial stdout and error", func(t *testing.T) {
135+ wantErr := errors.New("exit 1")
136+ portRunner := portsmocks.NewMockShellCommandRunner(t)
137+ portRunner.EXPECT().Run(mock.Anything, mock.Anything).Return([]byte("partial\x00 output\nignored"), wantErr)
138+ runner := barScriptRunner{runner: portRunner, timeout: time.Second}
139+
140+ got, err := runner.run(context.Background(), "false", barScriptContext{})
141+ if !errors.Is(err, wantErr) {
142+ t.Fatalf("run err = %v, want %v", err, wantErr)
143+ }
144+ if got != "partial output" {
145+ t.Fatalf("run output = %q, want %q", got, "partial output")
146+ }
147+ })
148+
117149 t.Run("timeout propagates deadline", func(t *testing.T) {
118150 portRunner := portsmocks.NewMockShellCommandRunner(t)
119151 portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.CommandSpec) ([]byte, error) {
@@ -83,9 +83,20 @@ func TestBarScriptContextEnv(t *testing.T) {
83 }83 }
84 84
85 func TestBarScriptRunner(t *testing.T) {85 func TestBarScriptRunner(t *testing.T) {
86+ t.Run("nil runner returns explicit error for command", func(t *testing.T) {
87+ runner := barScriptRunner{}
88+ got, err := runner.run(context.Background(), "date", barScriptContext{})
89+ if err == nil || err.Error() != "bar script command runner is nil" {
90+ t.Fatalf("run err = %v, want explicit nil runner error", err)
91+ }
92+ if got != "" {
93+ t.Fatalf("run output = %q, want empty", got)
94+ }
95+ })
96+
86 t.Run("empty command skips port runner", func(t *testing.T) {97 t.Run("empty command skips port runner", func(t *testing.T) {
87 runner := barScriptRunner{}98 runner := barScriptRunner{}
88- got, err := runner.run(context.Background(), "", barScriptContext{})99+ got, err := runner.run(context.Background(), " \t\n ", barScriptContext{})
89 if err != nil {100 if err != nil {
90 t.Fatalf("run err = %v, want nil", err)101 t.Fatalf("run err = %v, want nil", err)
91 }102 }
@@ -100,12 +111,18 @@ func TestBarScriptRunner(t *testing.T) {
100 return spec.Command == "printf 'ok\\nignored'" &&111 return spec.Command == "printf 'ok\\nignored'" &&
101 spec.Timeout == 50*time.Millisecond &&112 spec.Timeout == 50*time.Millisecond &&
102 spec.StdoutLimit == barScriptOutputLimit &&113 spec.StdoutLimit == barScriptOutputLimit &&
114+ containsEnv(spec.Env, "PATH=/bin") &&
103 containsEnv(spec.Env, "VEV_ANCHOR=top-right") &&115 containsEnv(spec.Env, "VEV_ANCHOR=top-right") &&
104- containsEnv(spec.Env, "VEV_COLS=120")116+ containsEnv(spec.Env, "VEV_SESSION=work") &&
105- })).Return([]byte("ok\nignored"), nil)117+ containsEnv(spec.Env, "VEV_TAB=tab-1") &&
118+ containsEnv(spec.Env, "VEV_PANE=pane-1") &&
119+ containsEnv(spec.Env, "VEV_PANE_CWD=/repo") &&
120+ containsEnv(spec.Env, "VEV_COLS=120") &&
121+ !containsEnv(spec.Env, "VEV_ANCHOR=old")
122+ })).Return([]byte("\x1b[31mok\x1b[0m\nignored"), nil)
106 runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond, baseEnv: []string{"PATH=/bin", "VEV_ANCHOR=old"}}123 runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond, baseEnv: []string{"PATH=/bin", "VEV_ANCHOR=old"}}
107 124
108- got, err := runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{Anchor: "top-right", Cols: 120})125+ got, err := runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{Anchor: "top-right", Session: "work", Tab: "tab-1", Pane: "pane-1", PaneCWD: "/repo", Cols: 120})
109 if err != nil {126 if err != nil {
110 t.Fatalf("run err = %v, want nil", err)127 t.Fatalf("run err = %v, want nil", err)
111 }128 }
@@ -114,6 +131,21 @@ func TestBarScriptRunner(t *testing.T) {
114 }131 }
115 })132 })
116 133
134+ t.Run("port failure returns sanitized partial stdout and error", func(t *testing.T) {
135+ wantErr := errors.New("exit 1")
136+ portRunner := portsmocks.NewMockShellCommandRunner(t)
137+ portRunner.EXPECT().Run(mock.Anything, mock.Anything).Return([]byte("partial\x00 output\nignored"), wantErr)
138+ runner := barScriptRunner{runner: portRunner, timeout: time.Second}
139+
140+ got, err := runner.run(context.Background(), "false", barScriptContext{})
141+ if !errors.Is(err, wantErr) {
142+ t.Fatalf("run err = %v, want %v", err, wantErr)
143+ }
144+ if got != "partial output" {
145+ t.Fatalf("run output = %q, want %q", got, "partial output")
146+ }
147+ })
148+
117 t.Run("timeout propagates deadline", func(t *testing.T) {149 t.Run("timeout propagates deadline", func(t *testing.T) {
118 portRunner := portsmocks.NewMockShellCommandRunner(t)150 portRunner := portsmocks.NewMockShellCommandRunner(t)
119 portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.CommandSpec) ([]byte, error) {151 portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.CommandSpec) ([]byte, error) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close