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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 19 | 19 | }{ | |
| 20 | 20 | {name: "success", command: "printf 'ok'", limit: 1024, want: "ok"}, | |
| 21 | 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 | 26 | for _, tt := range tests { | |
| 24 | 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 | 73 | func TestRunnerRunBackgroundChildDoesNotWedgeAfterTimeout(t *testing.T) { | |
| 40 | 74 | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) | |
| 41 | 75 | 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) { | |||
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | 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 | 97 | t.Run("empty command skips port runner", func(t *testing.T) { | |
| 87 | 98 | runner := barScriptRunner{} | |
| 88 | - | got, err := runner.run(context.Background(), "", barScriptContext{}) | |
| 99 | + | got, err := runner.run(context.Background(), " \t\n ", barScriptContext{}) | |
| 89 | 100 | if err != nil { | |
| 90 | 101 | t.Fatalf("run err = %v, want nil", err) | |
| 91 | 102 | } | |
| @@ -100,12 +111,18 @@ func TestBarScriptRunner(t *testing.T) { | |||
| 100 | 111 | return spec.Command == "printf 'ok\\nignored'" && | |
| 101 | 112 | spec.Timeout == 50*time.Millisecond && | |
| 102 | 113 | spec.StdoutLimit == barScriptOutputLimit && | |
| 114 | + | containsEnv(spec.Env, "PATH=/bin") && | |
| 103 | 115 | 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) | |
| 106 | 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 | 126 | if err != nil { | |
| 110 | 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 | 149 | t.Run("timeout propagates deadline", func(t *testing.T) { | |
| 118 | 150 | portRunner := portsmocks.NewMockShellCommandRunner(t) | |
| 119 | 151 | 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) { | ||