refactor(daemon): move bar script execution behind port
Signed-off-by: brice <b@bnema.dev>
- commit
- 9182b6bf7fd91b1d5566234be6d8cef38d1ea147
- author
- brice · · 2026-07-06 16:36 UTC
- parent
- edf4b33
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +326 -83
A internal/adapters/shellcmd/runner.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | package shellcmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "io" | |
| 7 | + | "os/exec" | |
| 8 | + | "syscall" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // Runner executes bounded shell commands for bar scripts and similar usecases. | |
| 14 | + | type Runner struct{} | |
| 15 | + | ||
| 16 | + | // New returns a production shell command runner. | |
| 17 | + | func New() Runner { return Runner{} } | |
| 18 | + | ||
| 19 | + | // Run executes spec.Command through sh -c, capturing bounded stdout and discarding stderr. | |
| 20 | + | func (Runner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) { | |
| 21 | + | cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command) | |
| 22 | + | cmd.Env = spec.Env | |
| 23 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |
| 24 | + | cmd.WaitDelay = spec.Timeout | |
| 25 | + | cmd.Cancel = func() error { | |
| 26 | + | if cmd.Process == nil { | |
| 27 | + | return nil | |
| 28 | + | } | |
| 29 | + | return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | |
| 30 | + | } | |
| 31 | + | var stdout boundedBuffer | |
| 32 | + | stdout.limit = spec.StdoutLimit | |
| 33 | + | cmd.Stdout = &stdout | |
| 34 | + | cmd.Stderr = io.Discard | |
| 35 | + | if err := cmd.Run(); err != nil { | |
| 36 | + | return stdout.Bytes(), err | |
| 37 | + | } | |
| 38 | + | return stdout.Bytes(), nil | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | type boundedBuffer struct { | |
| 42 | + | buf bytes.Buffer | |
| 43 | + | limit int | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (b *boundedBuffer) Write(p []byte) (int, error) { | |
| 47 | + | if b.limit <= 0 { | |
| 48 | + | return len(p), nil | |
| 49 | + | } | |
| 50 | + | remaining := b.limit - b.buf.Len() | |
| 51 | + | if remaining > 0 { | |
| 52 | + | if len(p) < remaining { | |
| 53 | + | remaining = len(p) | |
| 54 | + | } | |
| 55 | + | _, _ = b.buf.Write(p[:remaining]) | |
| 56 | + | } | |
| 57 | + | return len(p), nil | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func (b *boundedBuffer) Bytes() []byte { return b.buf.Bytes() } | |
| 61 | + | ||
| 62 | + | var _ ports.ShellCommandRunner = Runner{} | |
| 63 | + | var _ io.Writer = (*boundedBuffer)(nil) | |
| @@ -0,0 +1,63 @@ | |||||
| 1 | + | package shellcmd | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "io" | |||
| 7 | + | "os/exec" | |||
| 8 | + | "syscall" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // Runner executes bounded shell commands for bar scripts and similar usecases. | |||
| 14 | + | type Runner struct{} | |||
| 15 | + | ||||
| 16 | + | // New returns a production shell command runner. | |||
| 17 | + | func New() Runner { return Runner{} } | |||
| 18 | + | ||||
| 19 | + | // Run executes spec.Command through sh -c, capturing bounded stdout and discarding stderr. | |||
| 20 | + | func (Runner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) { | |||
| 21 | + | cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command) | |||
| 22 | + | cmd.Env = spec.Env | |||
| 23 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |||
| 24 | + | cmd.WaitDelay = spec.Timeout | |||
| 25 | + | cmd.Cancel = func() error { | |||
| 26 | + | if cmd.Process == nil { | |||
| 27 | + | return nil | |||
| 28 | + | } | |||
| 29 | + | return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | |||
| 30 | + | } | |||
| 31 | + | var stdout boundedBuffer | |||
| 32 | + | stdout.limit = spec.StdoutLimit | |||
| 33 | + | cmd.Stdout = &stdout | |||
| 34 | + | cmd.Stderr = io.Discard | |||
| 35 | + | if err := cmd.Run(); err != nil { | |||
| 36 | + | return stdout.Bytes(), err | |||
| 37 | + | } | |||
| 38 | + | return stdout.Bytes(), nil | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | type boundedBuffer struct { | |||
| 42 | + | buf bytes.Buffer | |||
| 43 | + | limit int | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (b *boundedBuffer) Write(p []byte) (int, error) { | |||
| 47 | + | if b.limit <= 0 { | |||
| 48 | + | return len(p), nil | |||
| 49 | + | } | |||
| 50 | + | remaining := b.limit - b.buf.Len() | |||
| 51 | + | if remaining > 0 { | |||
| 52 | + | if len(p) < remaining { | |||
| 53 | + | remaining = len(p) | |||
| 54 | + | } | |||
| 55 | + | _, _ = b.buf.Write(p[:remaining]) | |||
| 56 | + | } | |||
| 57 | + | return len(p), nil | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func (b *boundedBuffer) Bytes() []byte { return b.buf.Bytes() } | |||
| 61 | + | ||||
| 62 | + | var _ ports.ShellCommandRunner = Runner{} | |||
| 63 | + | var _ io.Writer = (*boundedBuffer)(nil) | |||
A internal/adapters/shellcmd/runner_test.go +73 -0
| @@ -0,0 +1,73 @@ | |||
| 1 | + | package shellcmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestRunnerRun(t *testing.T) { | |
| 14 | + | tests := []struct { | |
| 15 | + | name string | |
| 16 | + | command string | |
| 17 | + | limit int | |
| 18 | + | want string | |
| 19 | + | }{ | |
| 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"}, | |
| 22 | + | } | |
| 23 | + | for _, tt := range tests { | |
| 24 | + | t.Run(tt.name, func(t *testing.T) { | |
| 25 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |
| 26 | + | defer cancel() | |
| 27 | + | ||
| 28 | + | got, err := New().Run(ctx, ports.CommandSpec{Command: tt.command, Timeout: time.Second, StdoutLimit: tt.limit}) | |
| 29 | + | if err != nil { | |
| 30 | + | t.Fatalf("Run() error = %v", err) | |
| 31 | + | } | |
| 32 | + | if string(got) != tt.want { | |
| 33 | + | t.Fatalf("Run() = %q, want %q", string(got), tt.want) | |
| 34 | + | } | |
| 35 | + | }) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestRunnerRunBackgroundChildDoesNotWedgeAfterTimeout(t *testing.T) { | |
| 40 | + | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) | |
| 41 | + | defer cancel() | |
| 42 | + | start := time.Now() | |
| 43 | + | ||
| 44 | + | _, err := New().Run(ctx, ports.CommandSpec{Command: "sleep 1 &", Timeout: 50 * time.Millisecond, StdoutLimit: 1024}) | |
| 45 | + | ||
| 46 | + | if err == nil { | |
| 47 | + | t.Fatal("Run() error = nil, want timeout or wait error") | |
| 48 | + | } | |
| 49 | + | if !errors.Is(ctx.Err(), context.DeadlineExceeded) { | |
| 50 | + | t.Fatalf("context error = %v, want DeadlineExceeded", ctx.Err()) | |
| 51 | + | } | |
| 52 | + | if elapsed := time.Since(start); elapsed > 500*time.Millisecond { | |
| 53 | + | t.Fatalf("Run() elapsed = %s, want <= 500ms", elapsed) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestRunnerRunUsesProvidedEnvironment(t *testing.T) { | |
| 58 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |
| 59 | + | defer cancel() | |
| 60 | + | ||
| 61 | + | got, err := New().Run(ctx, ports.CommandSpec{ | |
| 62 | + | Command: "printf '%s' \"$VEV_TEST_VALUE\"", | |
| 63 | + | Env: []string{"PATH=/bin:/usr/bin", "VEV_TEST_VALUE=from-env"}, | |
| 64 | + | Timeout: time.Second, | |
| 65 | + | StdoutLimit: 1024, | |
| 66 | + | }) | |
| 67 | + | if err != nil { | |
| 68 | + | t.Fatalf("Run() error = %v", err) | |
| 69 | + | } | |
| 70 | + | if strings.TrimSpace(string(got)) != "from-env" { | |
| 71 | + | t.Fatalf("Run() env output = %q, want from-env", string(got)) | |
| 72 | + | } | |
| 73 | + | } | |
| @@ -0,0 +1,73 @@ | |||||
| 1 | + | package shellcmd | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestRunnerRun(t *testing.T) { | |||
| 14 | + | tests := []struct { | |||
| 15 | + | name string | |||
| 16 | + | command string | |||
| 17 | + | limit int | |||
| 18 | + | want string | |||
| 19 | + | }{ | |||
| 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"}, | |||
| 22 | + | } | |||
| 23 | + | for _, tt := range tests { | |||
| 24 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 25 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |||
| 26 | + | defer cancel() | |||
| 27 | + | ||||
| 28 | + | got, err := New().Run(ctx, ports.CommandSpec{Command: tt.command, Timeout: time.Second, StdoutLimit: tt.limit}) | |||
| 29 | + | if err != nil { | |||
| 30 | + | t.Fatalf("Run() error = %v", err) | |||
| 31 | + | } | |||
| 32 | + | if string(got) != tt.want { | |||
| 33 | + | t.Fatalf("Run() = %q, want %q", string(got), tt.want) | |||
| 34 | + | } | |||
| 35 | + | }) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestRunnerRunBackgroundChildDoesNotWedgeAfterTimeout(t *testing.T) { | |||
| 40 | + | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) | |||
| 41 | + | defer cancel() | |||
| 42 | + | start := time.Now() | |||
| 43 | + | ||||
| 44 | + | _, err := New().Run(ctx, ports.CommandSpec{Command: "sleep 1 &", Timeout: 50 * time.Millisecond, StdoutLimit: 1024}) | |||
| 45 | + | ||||
| 46 | + | if err == nil { | |||
| 47 | + | t.Fatal("Run() error = nil, want timeout or wait error") | |||
| 48 | + | } | |||
| 49 | + | if !errors.Is(ctx.Err(), context.DeadlineExceeded) { | |||
| 50 | + | t.Fatalf("context error = %v, want DeadlineExceeded", ctx.Err()) | |||
| 51 | + | } | |||
| 52 | + | if elapsed := time.Since(start); elapsed > 500*time.Millisecond { | |||
| 53 | + | t.Fatalf("Run() elapsed = %s, want <= 500ms", elapsed) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestRunnerRunUsesProvidedEnvironment(t *testing.T) { | |||
| 58 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |||
| 59 | + | defer cancel() | |||
| 60 | + | ||||
| 61 | + | got, err := New().Run(ctx, ports.CommandSpec{ | |||
| 62 | + | Command: "printf '%s' \"$VEV_TEST_VALUE\"", | |||
| 63 | + | Env: []string{"PATH=/bin:/usr/bin", "VEV_TEST_VALUE=from-env"}, | |||
| 64 | + | Timeout: time.Second, | |||
| 65 | + | StdoutLimit: 1024, | |||
| 66 | + | }) | |||
| 67 | + | if err != nil { | |||
| 68 | + | t.Fatalf("Run() error = %v", err) | |||
| 69 | + | } | |||
| 70 | + | if strings.TrimSpace(string(got)) != "from-env" { | |||
| 71 | + | t.Fatalf("Run() env output = %q, want from-env", string(got)) | |||
| 72 | + | } | |||
| 73 | + | } | |||
M internal/app/run.go +2 -0
| @@ -28,6 +28,7 @@ import ( | |||
| 28 | 28 | "github.com/bnema/vev/internal/adapters/dgram" | |
| 29 | 29 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 30 | 30 | "github.com/bnema/vev/internal/adapters/pty" | |
| 31 | + | "github.com/bnema/vev/internal/adapters/shellcmd" | |
| 31 | 32 | snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot" | |
| 32 | 33 | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 33 | 34 | "github.com/bnema/vev/internal/adapters/term" | |
| @@ -284,6 +285,7 @@ func runDaemon() error { | |||
| 284 | 285 | } | |
| 285 | 286 | logConfigWarnings(log, warnings) | |
| 286 | 287 | daemonOpts = append(daemonOpts, daemon.WithConfig(cfg)) | |
| 288 | + | daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New())) | |
| 287 | 289 | daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome)) | |
| 288 | 290 | daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir()))) | |
| 289 | 291 | storePath := persist.StorePath(platform.StateDir()) | |
| @@ -28,6 +28,7 @@ import ( | |||||
| 28 | "github.com/bnema/vev/internal/adapters/dgram" | 28 | "github.com/bnema/vev/internal/adapters/dgram" | ||
| 29 | "github.com/bnema/vev/internal/adapters/ipc" | 29 | "github.com/bnema/vev/internal/adapters/ipc" | ||
| 30 | "github.com/bnema/vev/internal/adapters/pty" | 30 | "github.com/bnema/vev/internal/adapters/pty" | ||
| 31 | + | "github.com/bnema/vev/internal/adapters/shellcmd" | |||
| 31 | snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot" | 32 | snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot" | ||
| 32 | "github.com/bnema/vev/internal/adapters/sshstdio" | 33 | "github.com/bnema/vev/internal/adapters/sshstdio" | ||
| 33 | "github.com/bnema/vev/internal/adapters/term" | 34 | "github.com/bnema/vev/internal/adapters/term" | ||
| @@ -284,6 +285,7 @@ func runDaemon() error { | |||||
| 284 | } | 285 | } | ||
| 285 | logConfigWarnings(log, warnings) | 286 | logConfigWarnings(log, warnings) | ||
| 286 | daemonOpts = append(daemonOpts, daemon.WithConfig(cfg)) | 287 | daemonOpts = append(daemonOpts, daemon.WithConfig(cfg)) | ||
| 288 | + | daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New())) | |||
| 287 | daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome)) | 289 | daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome)) | ||
| 288 | daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir()))) | 290 | daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir()))) | ||
| 289 | storePath := persist.StorePath(platform.StateDir()) | 291 | storePath := persist.StorePath(platform.StateDir()) | ||
M internal/ports/mocks/mocks.go +95 -0
| @@ -2068,6 +2068,101 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal | |||
| 2068 | 2068 | return _c | |
| 2069 | 2069 | } | |
| 2070 | 2070 | ||
| 2071 | + | // NewMockShellCommandRunner creates a new instance of MockShellCommandRunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 2072 | + | // The first argument is typically a *testing.T value. | |
| 2073 | + | func NewMockShellCommandRunner(t interface { | |
| 2074 | + | mock.TestingT | |
| 2075 | + | Cleanup(func()) | |
| 2076 | + | }) *MockShellCommandRunner { | |
| 2077 | + | mock := &MockShellCommandRunner{} | |
| 2078 | + | mock.Mock.Test(t) | |
| 2079 | + | ||
| 2080 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 2081 | + | ||
| 2082 | + | return mock | |
| 2083 | + | } | |
| 2084 | + | ||
| 2085 | + | // MockShellCommandRunner is an autogenerated mock type for the ShellCommandRunner type | |
| 2086 | + | type MockShellCommandRunner struct { | |
| 2087 | + | mock.Mock | |
| 2088 | + | } | |
| 2089 | + | ||
| 2090 | + | type MockShellCommandRunner_Expecter struct { | |
| 2091 | + | mock *mock.Mock | |
| 2092 | + | } | |
| 2093 | + | ||
| 2094 | + | func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter { | |
| 2095 | + | return &MockShellCommandRunner_Expecter{mock: &_m.Mock} | |
| 2096 | + | } | |
| 2097 | + | ||
| 2098 | + | // Run provides a mock function for the type MockShellCommandRunner | |
| 2099 | + | func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) { | |
| 2100 | + | ret := _mock.Called(ctx, spec) | |
| 2101 | + | ||
| 2102 | + | if len(ret) == 0 { | |
| 2103 | + | panic("no return value specified for Run") | |
| 2104 | + | } | |
| 2105 | + | ||
| 2106 | + | var r0 []byte | |
| 2107 | + | var r1 error | |
| 2108 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ([]byte, error)); ok { | |
| 2109 | + | return returnFunc(ctx, spec) | |
| 2110 | + | } | |
| 2111 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) []byte); ok { | |
| 2112 | + | r0 = returnFunc(ctx, spec) | |
| 2113 | + | } else { | |
| 2114 | + | if ret.Get(0) != nil { | |
| 2115 | + | r0 = ret.Get(0).([]byte) | |
| 2116 | + | } | |
| 2117 | + | } | |
| 2118 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok { | |
| 2119 | + | r1 = returnFunc(ctx, spec) | |
| 2120 | + | } else { | |
| 2121 | + | r1 = ret.Error(1) | |
| 2122 | + | } | |
| 2123 | + | return r0, r1 | |
| 2124 | + | } | |
| 2125 | + | ||
| 2126 | + | // MockShellCommandRunner_Run_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Run' | |
| 2127 | + | type MockShellCommandRunner_Run_Call struct { | |
| 2128 | + | *mock.Call | |
| 2129 | + | } | |
| 2130 | + | ||
| 2131 | + | // Run is a helper method to define mock.On call | |
| 2132 | + | // - ctx context.Context | |
| 2133 | + | // - spec ports.CommandSpec | |
| 2134 | + | func (_e *MockShellCommandRunner_Expecter) Run(ctx any, spec any) *MockShellCommandRunner_Run_Call { | |
| 2135 | + | return &MockShellCommandRunner_Run_Call{Call: _e.mock.On("Run", ctx, spec)} | |
| 2136 | + | } | |
| 2137 | + | ||
| 2138 | + | func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spec ports.CommandSpec)) *MockShellCommandRunner_Run_Call { | |
| 2139 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2140 | + | var arg0 context.Context | |
| 2141 | + | if args[0] != nil { | |
| 2142 | + | arg0 = args[0].(context.Context) | |
| 2143 | + | } | |
| 2144 | + | var arg1 ports.CommandSpec | |
| 2145 | + | if args[1] != nil { | |
| 2146 | + | arg1 = args[1].(ports.CommandSpec) | |
| 2147 | + | } | |
| 2148 | + | run( | |
| 2149 | + | arg0, | |
| 2150 | + | arg1, | |
| 2151 | + | ) | |
| 2152 | + | }) | |
| 2153 | + | return _c | |
| 2154 | + | } | |
| 2155 | + | ||
| 2156 | + | func (_c *MockShellCommandRunner_Run_Call) Return(bytes []byte, err error) *MockShellCommandRunner_Run_Call { | |
| 2157 | + | _c.Call.Return(bytes, err) | |
| 2158 | + | return _c | |
| 2159 | + | } | |
| 2160 | + | ||
| 2161 | + | func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) ([]byte, error)) *MockShellCommandRunner_Run_Call { | |
| 2162 | + | _c.Call.Return(run) | |
| 2163 | + | return _c | |
| 2164 | + | } | |
| 2165 | + | ||
| 2071 | 2166 | // NewMockProcessInspector creates a new instance of MockProcessInspector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 2072 | 2167 | // The first argument is typically a *testing.T value. | |
| 2073 | 2168 | func NewMockProcessInspector(t interface { | |
| @@ -2068,6 +2068,101 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal | |||||
| 2068 | return _c | 2068 | return _c | ||
| 2069 | } | 2069 | } | ||
| 2070 | 2070 | ||||
| 2071 | + | // NewMockShellCommandRunner creates a new instance of MockShellCommandRunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 2072 | + | // The first argument is typically a *testing.T value. | |||
| 2073 | + | func NewMockShellCommandRunner(t interface { | |||
| 2074 | + | mock.TestingT | |||
| 2075 | + | Cleanup(func()) | |||
| 2076 | + | }) *MockShellCommandRunner { | |||
| 2077 | + | mock := &MockShellCommandRunner{} | |||
| 2078 | + | mock.Mock.Test(t) | |||
| 2079 | + | ||||
| 2080 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 2081 | + | ||||
| 2082 | + | return mock | |||
| 2083 | + | } | |||
| 2084 | + | ||||
| 2085 | + | // MockShellCommandRunner is an autogenerated mock type for the ShellCommandRunner type | |||
| 2086 | + | type MockShellCommandRunner struct { | |||
| 2087 | + | mock.Mock | |||
| 2088 | + | } | |||
| 2089 | + | ||||
| 2090 | + | type MockShellCommandRunner_Expecter struct { | |||
| 2091 | + | mock *mock.Mock | |||
| 2092 | + | } | |||
| 2093 | + | ||||
| 2094 | + | func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter { | |||
| 2095 | + | return &MockShellCommandRunner_Expecter{mock: &_m.Mock} | |||
| 2096 | + | } | |||
| 2097 | + | ||||
| 2098 | + | // Run provides a mock function for the type MockShellCommandRunner | |||
| 2099 | + | func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) { | |||
| 2100 | + | ret := _mock.Called(ctx, spec) | |||
| 2101 | + | ||||
| 2102 | + | if len(ret) == 0 { | |||
| 2103 | + | panic("no return value specified for Run") | |||
| 2104 | + | } | |||
| 2105 | + | ||||
| 2106 | + | var r0 []byte | |||
| 2107 | + | var r1 error | |||
| 2108 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ([]byte, error)); ok { | |||
| 2109 | + | return returnFunc(ctx, spec) | |||
| 2110 | + | } | |||
| 2111 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) []byte); ok { | |||
| 2112 | + | r0 = returnFunc(ctx, spec) | |||
| 2113 | + | } else { | |||
| 2114 | + | if ret.Get(0) != nil { | |||
| 2115 | + | r0 = ret.Get(0).([]byte) | |||
| 2116 | + | } | |||
| 2117 | + | } | |||
| 2118 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok { | |||
| 2119 | + | r1 = returnFunc(ctx, spec) | |||
| 2120 | + | } else { | |||
| 2121 | + | r1 = ret.Error(1) | |||
| 2122 | + | } | |||
| 2123 | + | return r0, r1 | |||
| 2124 | + | } | |||
| 2125 | + | ||||
| 2126 | + | // MockShellCommandRunner_Run_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Run' | |||
| 2127 | + | type MockShellCommandRunner_Run_Call struct { | |||
| 2128 | + | *mock.Call | |||
| 2129 | + | } | |||
| 2130 | + | ||||
| 2131 | + | // Run is a helper method to define mock.On call | |||
| 2132 | + | // - ctx context.Context | |||
| 2133 | + | // - spec ports.CommandSpec | |||
| 2134 | + | func (_e *MockShellCommandRunner_Expecter) Run(ctx any, spec any) *MockShellCommandRunner_Run_Call { | |||
| 2135 | + | return &MockShellCommandRunner_Run_Call{Call: _e.mock.On("Run", ctx, spec)} | |||
| 2136 | + | } | |||
| 2137 | + | ||||
| 2138 | + | func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spec ports.CommandSpec)) *MockShellCommandRunner_Run_Call { | |||
| 2139 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2140 | + | var arg0 context.Context | |||
| 2141 | + | if args[0] != nil { | |||
| 2142 | + | arg0 = args[0].(context.Context) | |||
| 2143 | + | } | |||
| 2144 | + | var arg1 ports.CommandSpec | |||
| 2145 | + | if args[1] != nil { | |||
| 2146 | + | arg1 = args[1].(ports.CommandSpec) | |||
| 2147 | + | } | |||
| 2148 | + | run( | |||
| 2149 | + | arg0, | |||
| 2150 | + | arg1, | |||
| 2151 | + | ) | |||
| 2152 | + | }) | |||
| 2153 | + | return _c | |||
| 2154 | + | } | |||
| 2155 | + | ||||
| 2156 | + | func (_c *MockShellCommandRunner_Run_Call) Return(bytes []byte, err error) *MockShellCommandRunner_Run_Call { | |||
| 2157 | + | _c.Call.Return(bytes, err) | |||
| 2158 | + | return _c | |||
| 2159 | + | } | |||
| 2160 | + | ||||
| 2161 | + | func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) ([]byte, error)) *MockShellCommandRunner_Run_Call { | |||
| 2162 | + | _c.Call.Return(run) | |||
| 2163 | + | return _c | |||
| 2164 | + | } | |||
| 2165 | + | ||||
| 2071 | // NewMockProcessInspector creates a new instance of MockProcessInspector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 2166 | // NewMockProcessInspector creates a new instance of MockProcessInspector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 2072 | // The first argument is typically a *testing.T value. | 2167 | // The first argument is typically a *testing.T value. | ||
| 2073 | func NewMockProcessInspector(t interface { | 2168 | func NewMockProcessInspector(t interface { | ||
M internal/ports/ports.go +13 -0
| @@ -96,6 +96,19 @@ type Timer interface { | |||
| 96 | 96 | Stop() bool | |
| 97 | 97 | } | |
| 98 | 98 | ||
| 99 | + | // CommandSpec describes a bounded shell command execution request. | |
| 100 | + | type CommandSpec struct { | |
| 101 | + | Command string | |
| 102 | + | Env []string | |
| 103 | + | Timeout time.Duration | |
| 104 | + | StdoutLimit int | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // ShellCommandRunner runs shell commands and returns bounded stdout bytes. | |
| 108 | + | type ShellCommandRunner interface { | |
| 109 | + | Run(ctx context.Context, spec CommandSpec) ([]byte, error) | |
| 110 | + | } | |
| 111 | + | ||
| 99 | 112 | // ProcessInspector reads process metadata for daemon snapshot/persistence use. | |
| 100 | 113 | // Implementations may live in platform packages; usecases depend on this port. | |
| 101 | 114 | type ProcessInspector interface { | |
| @@ -96,6 +96,19 @@ type Timer interface { | |||||
| 96 | Stop() bool | 96 | Stop() bool | ||
| 97 | } | 97 | } | ||
| 98 | 98 | ||||
| 99 | + | // CommandSpec describes a bounded shell command execution request. | |||
| 100 | + | type CommandSpec struct { | |||
| 101 | + | Command string | |||
| 102 | + | Env []string | |||
| 103 | + | Timeout time.Duration | |||
| 104 | + | StdoutLimit int | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // ShellCommandRunner runs shell commands and returns bounded stdout bytes. | |||
| 108 | + | type ShellCommandRunner interface { | |||
| 109 | + | Run(ctx context.Context, spec CommandSpec) ([]byte, error) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 99 | // ProcessInspector reads process metadata for daemon snapshot/persistence use. | 112 | // ProcessInspector reads process metadata for daemon snapshot/persistence use. | ||
| 100 | // Implementations may live in platform packages; usecases depend on this port. | 113 | // Implementations may live in platform packages; usecases depend on this port. | ||
| 101 | type ProcessInspector interface { | 114 | type ProcessInspector interface { | ||
M internal/usecase/daemon/bar_script.go +13 -43
| @@ -4,13 +4,12 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | - | "io" | |
| 8 | - | "os/exec" | |
| 9 | 7 | "strconv" | |
| 10 | 8 | "strings" | |
| 11 | - | "syscall" | |
| 12 | 9 | "time" | |
| 13 | 10 | "unicode/utf8" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | 13 | ) | |
| 15 | 14 | ||
| 16 | 15 | const ( | |
| @@ -54,6 +53,7 @@ func (c barScriptContext) env(base []string) []string { | |||
| 54 | 53 | } | |
| 55 | 54 | ||
| 56 | 55 | type barScriptRunner struct { | |
| 56 | + | runner ports.ShellCommandRunner | |
| 57 | 57 | timeout time.Duration | |
| 58 | 58 | baseEnv []string | |
| 59 | 59 | } | |
| @@ -62,6 +62,9 @@ func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barS | |||
| 62 | 62 | if strings.TrimSpace(command) == "" { | |
| 63 | 63 | return "", nil | |
| 64 | 64 | } | |
| 65 | + | if r.runner == nil { | |
| 66 | + | return "", errors.New("bar script command runner is nil") | |
| 67 | + | } | |
| 65 | 68 | timeout := r.timeout | |
| 66 | 69 | if timeout <= 0 { | |
| 67 | 70 | timeout = barScriptTimeout | |
| @@ -69,53 +72,22 @@ func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barS | |||
| 69 | 72 | ctx, cancel := context.WithTimeout(ctx, timeout) | |
| 70 | 73 | defer cancel() | |
| 71 | 74 | ||
| 72 | - | cmd := exec.CommandContext(ctx, "sh", "-c", command) | |
| 73 | - | cmd.Env = scriptCtx.env(r.baseEnv) | |
| 74 | - | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |
| 75 | - | cmd.WaitDelay = timeout | |
| 76 | - | cmd.Cancel = func() error { | |
| 77 | - | if cmd.Process == nil { | |
| 78 | - | return nil | |
| 79 | - | } | |
| 80 | - | return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | |
| 81 | - | } | |
| 82 | - | var stdout boundedBuffer | |
| 83 | - | stdout.limit = barScriptOutputLimit | |
| 84 | - | cmd.Stdout = &stdout | |
| 85 | - | cmd.Stderr = io.Discard | |
| 86 | - | ||
| 87 | - | err := cmd.Run() | |
| 75 | + | stdout, err := r.runner.Run(ctx, ports.CommandSpec{ | |
| 76 | + | Command: command, | |
| 77 | + | Env: scriptCtx.env(r.baseEnv), | |
| 78 | + | Timeout: timeout, | |
| 79 | + | StdoutLimit: barScriptOutputLimit, | |
| 80 | + | }) | |
| 88 | 81 | if errors.Is(ctx.Err(), context.DeadlineExceeded) { | |
| 89 | 82 | return "", context.DeadlineExceeded | |
| 90 | 83 | } | |
| 91 | - | text := sanitizeBarScriptOutput(stdout.Bytes(), barScriptOutputLimit) | |
| 84 | + | text := sanitizeBarScriptOutput(stdout, barScriptOutputLimit) | |
| 92 | 85 | if err != nil { | |
| 93 | 86 | return text, err | |
| 94 | 87 | } | |
| 95 | 88 | return text, nil | |
| 96 | 89 | } | |
| 97 | 90 | ||
| 98 | - | type boundedBuffer struct { | |
| 99 | - | buf bytes.Buffer | |
| 100 | - | limit int | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | func (b *boundedBuffer) Write(p []byte) (int, error) { | |
| 104 | - | if b.limit <= 0 { | |
| 105 | - | return len(p), nil | |
| 106 | - | } | |
| 107 | - | remaining := b.limit - b.buf.Len() | |
| 108 | - | if remaining > 0 { | |
| 109 | - | if len(p) < remaining { | |
| 110 | - | remaining = len(p) | |
| 111 | - | } | |
| 112 | - | _, _ = b.buf.Write(p[:remaining]) | |
| 113 | - | } | |
| 114 | - | return len(p), nil | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | func (b *boundedBuffer) Bytes() []byte { return b.buf.Bytes() } | |
| 118 | - | ||
| 119 | 91 | func sanitizeBarScriptOutput(raw []byte, limit int) string { | |
| 120 | 92 | if limit <= 0 || limit > len(raw) { | |
| 121 | 93 | limit = len(raw) | |
| @@ -209,5 +181,3 @@ func trimUTF8Bytes(s string, limit int) string { | |||
| 209 | 181 | } | |
| 210 | 182 | return s[:limit] | |
| 211 | 183 | } | |
| 212 | - | ||
| 213 | - | var _ io.Writer = (*boundedBuffer)(nil) | |
| @@ -4,13 +4,12 @@ import ( | |||||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | - | "io" | |||
| 8 | - | "os/exec" | |||
| 9 | "strconv" | 7 | "strconv" | ||
| 10 | "strings" | 8 | "strings" | ||
| 11 | - | "syscall" | |||
| 12 | "time" | 9 | "time" | ||
| 13 | "unicode/utf8" | 10 | "unicode/utf8" | ||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 14 | ) | 13 | ) | ||
| 15 | 14 | ||||
| 16 | const ( | 15 | const ( | ||
| @@ -54,6 +53,7 @@ func (c barScriptContext) env(base []string) []string { | |||||
| 54 | } | 53 | } | ||
| 55 | 54 | ||||
| 56 | type barScriptRunner struct { | 55 | type barScriptRunner struct { | ||
| 56 | + | runner ports.ShellCommandRunner | |||
| 57 | timeout time.Duration | 57 | timeout time.Duration | ||
| 58 | baseEnv []string | 58 | baseEnv []string | ||
| 59 | } | 59 | } | ||
| @@ -62,6 +62,9 @@ func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barS | |||||
| 62 | if strings.TrimSpace(command) == "" { | 62 | if strings.TrimSpace(command) == "" { | ||
| 63 | return "", nil | 63 | return "", nil | ||
| 64 | } | 64 | } | ||
| 65 | + | if r.runner == nil { | |||
| 66 | + | return "", errors.New("bar script command runner is nil") | |||
| 67 | + | } | |||
| 65 | timeout := r.timeout | 68 | timeout := r.timeout | ||
| 66 | if timeout <= 0 { | 69 | if timeout <= 0 { | ||
| 67 | timeout = barScriptTimeout | 70 | timeout = barScriptTimeout | ||
| @@ -69,53 +72,22 @@ func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barS | |||||
| 69 | ctx, cancel := context.WithTimeout(ctx, timeout) | 72 | ctx, cancel := context.WithTimeout(ctx, timeout) | ||
| 70 | defer cancel() | 73 | defer cancel() | ||
| 71 | 74 | ||||
| 72 | - | cmd := exec.CommandContext(ctx, "sh", "-c", command) | 75 | + | stdout, err := r.runner.Run(ctx, ports.CommandSpec{ |
| 73 | - | cmd.Env = scriptCtx.env(r.baseEnv) | 76 | + | Command: command, |
| 74 | - | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | 77 | + | Env: scriptCtx.env(r.baseEnv), |
| 75 | - | cmd.WaitDelay = timeout | 78 | + | Timeout: timeout, |
| 76 | - | cmd.Cancel = func() error { | 79 | + | StdoutLimit: barScriptOutputLimit, |
| 77 | - | if cmd.Process == nil { | 80 | + | }) |
| 78 | - | return nil | |||
| 79 | - | } | |||
| 80 | - | return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | |||
| 81 | - | } | |||
| 82 | - | var stdout boundedBuffer | |||
| 83 | - | stdout.limit = barScriptOutputLimit | |||
| 84 | - | cmd.Stdout = &stdout | |||
| 85 | - | cmd.Stderr = io.Discard | |||
| 86 | - | ||||
| 87 | - | err := cmd.Run() | |||
| 88 | if errors.Is(ctx.Err(), context.DeadlineExceeded) { | 81 | if errors.Is(ctx.Err(), context.DeadlineExceeded) { | ||
| 89 | return "", context.DeadlineExceeded | 82 | return "", context.DeadlineExceeded | ||
| 90 | } | 83 | } | ||
| 91 | - | text := sanitizeBarScriptOutput(stdout.Bytes(), barScriptOutputLimit) | 84 | + | text := sanitizeBarScriptOutput(stdout, barScriptOutputLimit) |
| 92 | if err != nil { | 85 | if err != nil { | ||
| 93 | return text, err | 86 | return text, err | ||
| 94 | } | 87 | } | ||
| 95 | return text, nil | 88 | return text, nil | ||
| 96 | } | 89 | } | ||
| 97 | 90 | ||||
| 98 | - | type boundedBuffer struct { | |||
| 99 | - | buf bytes.Buffer | |||
| 100 | - | limit int | |||
| 101 | - | } | |||
| 102 | - | ||||
| 103 | - | func (b *boundedBuffer) Write(p []byte) (int, error) { | |||
| 104 | - | if b.limit <= 0 { | |||
| 105 | - | return len(p), nil | |||
| 106 | - | } | |||
| 107 | - | remaining := b.limit - b.buf.Len() | |||
| 108 | - | if remaining > 0 { | |||
| 109 | - | if len(p) < remaining { | |||
| 110 | - | remaining = len(p) | |||
| 111 | - | } | |||
| 112 | - | _, _ = b.buf.Write(p[:remaining]) | |||
| 113 | - | } | |||
| 114 | - | return len(p), nil | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | - | func (b *boundedBuffer) Bytes() []byte { return b.buf.Bytes() } | |||
| 118 | - | ||||
| 119 | func sanitizeBarScriptOutput(raw []byte, limit int) string { | 91 | func sanitizeBarScriptOutput(raw []byte, limit int) string { | ||
| 120 | if limit <= 0 || limit > len(raw) { | 92 | if limit <= 0 || limit > len(raw) { | ||
| 121 | limit = len(raw) | 93 | limit = len(raw) | ||
| @@ -209,5 +181,3 @@ func trimUTF8Bytes(s string, limit int) string { | |||||
| 209 | } | 181 | } | ||
| 210 | return s[:limit] | 182 | return s[:limit] | ||
| 211 | } | 183 | } | ||
| 212 | - | ||||
| 213 | - | var _ io.Writer = (*boundedBuffer)(nil) | |||
M internal/usecase/daemon/bar_script_test.go +58 -39
| @@ -3,10 +3,14 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | - | "os" | |
| 6 | + | "slices" | |
| 7 | 7 | "strings" | |
| 8 | 8 | "testing" | |
| 9 | 9 | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 13 | + | "github.com/stretchr/testify/mock" | |
| 10 | 14 | ) | |
| 11 | 15 | ||
| 12 | 16 | func TestBarScriptSanitizeOutput(t *testing.T) { | |
| @@ -79,49 +83,60 @@ func TestBarScriptContextEnv(t *testing.T) { | |||
| 79 | 83 | } | |
| 80 | 84 | ||
| 81 | 85 | func TestBarScriptRunner(t *testing.T) { | |
| 82 | - | runner := barScriptRunner{timeout: 50 * time.Millisecond, baseEnv: os.Environ()} | |
| 83 | - | tests := []struct { | |
| 84 | - | name string | |
| 85 | - | command string | |
| 86 | - | want string | |
| 87 | - | wantErr error | |
| 88 | - | }{ | |
| 89 | - | {name: "empty command", command: "", want: ""}, | |
| 90 | - | {name: "success first line", command: "printf 'ok\\nignored'", want: "ok"}, | |
| 91 | - | {name: "timeout", command: "sleep 1; printf late", wantErr: context.DeadlineExceeded}, | |
| 92 | - | } | |
| 93 | - | for _, tt := range tests { | |
| 94 | - | t.Run(tt.name, func(t *testing.T) { | |
| 95 | - | got, err := runner.run(context.Background(), tt.command, barScriptContext{}) | |
| 96 | - | if tt.wantErr != nil { | |
| 97 | - | if !errors.Is(err, tt.wantErr) { | |
| 98 | - | t.Fatalf("run err = %v, want %v", err, tt.wantErr) | |
| 99 | - | } | |
| 100 | - | } else if err != nil { | |
| 101 | - | t.Fatalf("run err = %v, want nil", err) | |
| 102 | - | } | |
| 103 | - | if got != tt.want { | |
| 104 | - | t.Fatalf("run output = %q, want %q", got, tt.want) | |
| 105 | - | } | |
| 86 | + | t.Run("empty command skips port runner", func(t *testing.T) { | |
| 87 | + | runner := barScriptRunner{} | |
| 88 | + | got, err := runner.run(context.Background(), "", barScriptContext{}) | |
| 89 | + | if err != nil { | |
| 90 | + | t.Fatalf("run err = %v, want nil", err) | |
| 91 | + | } | |
| 92 | + | if got != "" { | |
| 93 | + | t.Fatalf("run output = %q, want empty", got) | |
| 94 | + | } | |
| 95 | + | }) | |
| 96 | + | ||
| 97 | + | t.Run("success sanitizes first line and passes command spec", func(t *testing.T) { | |
| 98 | + | portRunner := portsmocks.NewMockShellCommandRunner(t) | |
| 99 | + | portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool { | |
| 100 | + | return spec.Command == "printf 'ok\\nignored'" && | |
| 101 | + | spec.Timeout == 50*time.Millisecond && | |
| 102 | + | spec.StdoutLimit == barScriptOutputLimit && | |
| 103 | + | containsEnv(spec.Env, "VEV_ANCHOR=top-right") && | |
| 104 | + | containsEnv(spec.Env, "VEV_COLS=120") | |
| 105 | + | })).Return([]byte("ok\nignored"), nil) | |
| 106 | + | runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond, baseEnv: []string{"PATH=/bin", "VEV_ANCHOR=old"}} | |
| 107 | + | ||
| 108 | + | got, err := runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{Anchor: "top-right", Cols: 120}) | |
| 109 | + | if err != nil { | |
| 110 | + | t.Fatalf("run err = %v, want nil", err) | |
| 111 | + | } | |
| 112 | + | if got != "ok" { | |
| 113 | + | t.Fatalf("run output = %q, want %q", got, "ok") | |
| 114 | + | } | |
| 115 | + | }) | |
| 116 | + | ||
| 117 | + | t.Run("timeout propagates deadline", func(t *testing.T) { | |
| 118 | + | portRunner := portsmocks.NewMockShellCommandRunner(t) | |
| 119 | + | portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.CommandSpec) ([]byte, error) { | |
| 120 | + | <-ctx.Done() | |
| 121 | + | return nil, ctx.Err() | |
| 106 | 122 | }) | |
| 107 | - | } | |
| 108 | - | } | |
| 123 | + | runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond} | |
| 109 | 124 | ||
| 110 | - | func TestBarScriptRunnerBackgroundChildStdoutTimeout(t *testing.T) { | |
| 111 | - | runner := barScriptRunner{timeout: 50 * time.Millisecond, baseEnv: os.Environ()} | |
| 112 | - | start := time.Now() | |
| 113 | - | _, err := runner.run(context.Background(), "sleep 1 &", barScriptContext{}) | |
| 114 | - | if err == nil { | |
| 115 | - | t.Fatalf("background child run err = nil, want timeout/wait error") | |
| 116 | - | } | |
| 117 | - | if elapsed := time.Since(start); elapsed > 500*time.Millisecond { | |
| 118 | - | t.Fatalf("background child run elapsed = %s, want <= 500ms", elapsed) | |
| 119 | - | } | |
| 125 | + | _, err := runner.run(context.Background(), "sleep 1", barScriptContext{}) | |
| 126 | + | if !errors.Is(err, context.DeadlineExceeded) { | |
| 127 | + | t.Fatalf("run err = %v, want %v", err, context.DeadlineExceeded) | |
| 128 | + | } | |
| 129 | + | }) | |
| 120 | 130 | } | |
| 121 | 131 | ||
| 122 | 132 | func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) { | |
| 123 | - | runner := barScriptRunner{timeout: time.Second, baseEnv: os.Environ()} | |
| 124 | - | got, err := runner.run(context.Background(), "yes a | head -c 4096", barScriptContext{}) | |
| 133 | + | portRunner := portsmocks.NewMockShellCommandRunner(t) | |
| 134 | + | portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool { | |
| 135 | + | return spec.StdoutLimit == barScriptOutputLimit | |
| 136 | + | })).Return([]byte(strings.Repeat("a", 4096)), nil) | |
| 137 | + | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | |
| 138 | + | ||
| 139 | + | got, err := runner.run(context.Background(), "long-output", barScriptContext{}) | |
| 125 | 140 | if err != nil { | |
| 126 | 141 | t.Fatalf("run bounded output: %v", err) | |
| 127 | 142 | } | |
| @@ -129,3 +144,7 @@ func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) { | |||
| 129 | 144 | t.Fatalf("output length = %d, want <= %d", len(got), barScriptDisplayLimit) | |
| 130 | 145 | } | |
| 131 | 146 | } | |
| 147 | + | ||
| 148 | + | func containsEnv(env []string, entry string) bool { | |
| 149 | + | return slices.Contains(env, entry) | |
| 150 | + | } | |
| @@ -3,10 +3,14 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | - | "os" | 6 | + | "slices" |
| 7 | "strings" | 7 | "strings" | ||
| 8 | "testing" | 8 | "testing" | ||
| 9 | "time" | 9 | "time" | ||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/ports" | |||
| 12 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 10 | ) | 14 | ) | ||
| 11 | 15 | ||||
| 12 | func TestBarScriptSanitizeOutput(t *testing.T) { | 16 | func TestBarScriptSanitizeOutput(t *testing.T) { | ||
| @@ -79,49 +83,60 @@ func TestBarScriptContextEnv(t *testing.T) { | |||||
| 79 | } | 83 | } | ||
| 80 | 84 | ||||
| 81 | func TestBarScriptRunner(t *testing.T) { | 85 | func TestBarScriptRunner(t *testing.T) { | ||
| 82 | - | runner := barScriptRunner{timeout: 50 * time.Millisecond, baseEnv: os.Environ()} | 86 | + | t.Run("empty command skips port runner", func(t *testing.T) { |
| 83 | - | tests := []struct { | 87 | + | runner := barScriptRunner{} |
| 84 | - | name string | 88 | + | got, err := runner.run(context.Background(), "", barScriptContext{}) |
| 85 | - | command string | 89 | + | if err != nil { |
| 86 | - | want string | 90 | + | t.Fatalf("run err = %v, want nil", err) |
| 87 | - | wantErr error | 91 | + | } |
| 88 | - | }{ | 92 | + | if got != "" { |
| 89 | - | {name: "empty command", command: "", want: ""}, | 93 | + | t.Fatalf("run output = %q, want empty", got) |
| 90 | - | {name: "success first line", command: "printf 'ok\\nignored'", want: "ok"}, | 94 | + | } |
| 91 | - | {name: "timeout", command: "sleep 1; printf late", wantErr: context.DeadlineExceeded}, | 95 | + | }) |
| 92 | - | } | 96 | + | |
| 93 | - | for _, tt := range tests { | 97 | + | t.Run("success sanitizes first line and passes command spec", func(t *testing.T) { |
| 94 | - | t.Run(tt.name, func(t *testing.T) { | 98 | + | portRunner := portsmocks.NewMockShellCommandRunner(t) |
| 95 | - | got, err := runner.run(context.Background(), tt.command, barScriptContext{}) | 99 | + | portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool { |
| 96 | - | if tt.wantErr != nil { | 100 | + | return spec.Command == "printf 'ok\\nignored'" && |
| 97 | - | if !errors.Is(err, tt.wantErr) { | 101 | + | spec.Timeout == 50*time.Millisecond && |
| 98 | - | t.Fatalf("run err = %v, want %v", err, tt.wantErr) | 102 | + | spec.StdoutLimit == barScriptOutputLimit && |
| 99 | - | } | 103 | + | containsEnv(spec.Env, "VEV_ANCHOR=top-right") && |
| 100 | - | } else if err != nil { | 104 | + | containsEnv(spec.Env, "VEV_COLS=120") |
| 101 | - | t.Fatalf("run err = %v, want nil", err) | 105 | + | })).Return([]byte("ok\nignored"), nil) |
| 102 | - | } | 106 | + | runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond, baseEnv: []string{"PATH=/bin", "VEV_ANCHOR=old"}} |
| 103 | - | if got != tt.want { | 107 | + | |
| 104 | - | t.Fatalf("run output = %q, want %q", got, tt.want) | 108 | + | got, err := runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{Anchor: "top-right", Cols: 120}) |
| 105 | - | } | 109 | + | if err != nil { |
| 110 | + | t.Fatalf("run err = %v, want nil", err) | |||
| 111 | + | } | |||
| 112 | + | if got != "ok" { | |||
| 113 | + | t.Fatalf("run output = %q, want %q", got, "ok") | |||
| 114 | + | } | |||
| 115 | + | }) | |||
| 116 | + | ||||
| 117 | + | t.Run("timeout propagates deadline", func(t *testing.T) { | |||
| 118 | + | portRunner := portsmocks.NewMockShellCommandRunner(t) | |||
| 119 | + | portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.CommandSpec) ([]byte, error) { | |||
| 120 | + | <-ctx.Done() | |||
| 121 | + | return nil, ctx.Err() | |||
| 106 | }) | 122 | }) | ||
| 107 | - | } | 123 | + | runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond} |
| 108 | - | } | |||
| 109 | 124 | ||||
| 110 | - | func TestBarScriptRunnerBackgroundChildStdoutTimeout(t *testing.T) { | 125 | + | _, err := runner.run(context.Background(), "sleep 1", barScriptContext{}) |
| 111 | - | runner := barScriptRunner{timeout: 50 * time.Millisecond, baseEnv: os.Environ()} | 126 | + | if !errors.Is(err, context.DeadlineExceeded) { |
| 112 | - | start := time.Now() | 127 | + | t.Fatalf("run err = %v, want %v", err, context.DeadlineExceeded) |
| 113 | - | _, err := runner.run(context.Background(), "sleep 1 &", barScriptContext{}) | 128 | + | } |
| 114 | - | if err == nil { | 129 | + | }) |
| 115 | - | t.Fatalf("background child run err = nil, want timeout/wait error") | |||
| 116 | - | } | |||
| 117 | - | if elapsed := time.Since(start); elapsed > 500*time.Millisecond { | |||
| 118 | - | t.Fatalf("background child run elapsed = %s, want <= 500ms", elapsed) | |||
| 119 | - | } | |||
| 120 | } | 130 | } | ||
| 121 | 131 | ||||
| 122 | func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) { | 132 | func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) { | ||
| 123 | - | runner := barScriptRunner{timeout: time.Second, baseEnv: os.Environ()} | 133 | + | portRunner := portsmocks.NewMockShellCommandRunner(t) |
| 124 | - | got, err := runner.run(context.Background(), "yes a | head -c 4096", barScriptContext{}) | 134 | + | portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool { |
| 135 | + | return spec.StdoutLimit == barScriptOutputLimit | |||
| 136 | + | })).Return([]byte(strings.Repeat("a", 4096)), nil) | |||
| 137 | + | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | |||
| 138 | + | ||||
| 139 | + | got, err := runner.run(context.Background(), "long-output", barScriptContext{}) | |||
| 125 | if err != nil { | 140 | if err != nil { | ||
| 126 | t.Fatalf("run bounded output: %v", err) | 141 | t.Fatalf("run bounded output: %v", err) | ||
| 127 | } | 142 | } | ||
| @@ -129,3 +144,7 @@ func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) { | |||||
| 129 | t.Fatalf("output length = %d, want <= %d", len(got), barScriptDisplayLimit) | 144 | t.Fatalf("output length = %d, want <= %d", len(got), barScriptDisplayLimit) | ||
| 130 | } | 145 | } | ||
| 131 | } | 146 | } | ||
| 147 | + | ||||
| 148 | + | func containsEnv(env []string, entry string) bool { | |||
| 149 | + | return slices.Contains(env, entry) | |||
| 150 | + | } | |||
M internal/usecase/daemon/daemon.go +9 -1
| @@ -225,6 +225,15 @@ func WithTempDir(dir string) Option { | |||
| 225 | 225 | } | |
| 226 | 226 | } | |
| 227 | 227 | ||
| 228 | + | // WithBarScriptCommandRunner installs the shell command runner used by bar scripts. | |
| 229 | + | func WithBarScriptCommandRunner(runner ports.ShellCommandRunner) Option { | |
| 230 | + | return func(d *Daemon) { | |
| 231 | + | if runner != nil { | |
| 232 | + | d.barScripts.runner = barScriptRunner{runner: runner, baseEnv: d.baseEnv} | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 228 | 237 | // WithConfig applies the initial user configuration. | |
| 229 | 238 | func WithConfig(cfg domain.Config) Option { | |
| 230 | 239 | return func(d *Daemon) { | |
| @@ -258,7 +267,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 258 | 267 | animWake: make(chan struct{}, 1), | |
| 259 | 268 | barScripts: &barScriptState{ | |
| 260 | 269 | cfg: barConfigFromDomain(domain.Defaults().Bar), | |
| 261 | - | runner: barScriptRunner{baseEnv: os.Environ()}, | |
| 262 | 270 | outputs: make(map[domain.SessionID]barScriptOutputs), | |
| 263 | 271 | lastRefresh: make(map[domain.SessionID]time.Time), | |
| 264 | 272 | lastContext: make(map[domain.SessionID]barScriptContext), | |
| @@ -225,6 +225,15 @@ func WithTempDir(dir string) Option { | |||||
| 225 | } | 225 | } | ||
| 226 | } | 226 | } | ||
| 227 | 227 | ||||
| 228 | + | // WithBarScriptCommandRunner installs the shell command runner used by bar scripts. | |||
| 229 | + | func WithBarScriptCommandRunner(runner ports.ShellCommandRunner) Option { | |||
| 230 | + | return func(d *Daemon) { | |||
| 231 | + | if runner != nil { | |||
| 232 | + | d.barScripts.runner = barScriptRunner{runner: runner, baseEnv: d.baseEnv} | |||
| 233 | + | } | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | ||||
| 228 | // WithConfig applies the initial user configuration. | 237 | // WithConfig applies the initial user configuration. | ||
| 229 | func WithConfig(cfg domain.Config) Option { | 238 | func WithConfig(cfg domain.Config) Option { | ||
| 230 | return func(d *Daemon) { | 239 | return func(d *Daemon) { | ||
| @@ -258,7 +267,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 258 | animWake: make(chan struct{}, 1), | 267 | animWake: make(chan struct{}, 1), | ||
| 259 | barScripts: &barScriptState{ | 268 | barScripts: &barScriptState{ | ||
| 260 | cfg: barConfigFromDomain(domain.Defaults().Bar), | 269 | cfg: barConfigFromDomain(domain.Defaults().Bar), | ||
| 261 | - | runner: barScriptRunner{baseEnv: os.Environ()}, | |||
| 262 | outputs: make(map[domain.SessionID]barScriptOutputs), | 270 | outputs: make(map[domain.SessionID]barScriptOutputs), | ||
| 263 | lastRefresh: make(map[domain.SessionID]time.Time), | 271 | lastRefresh: make(map[domain.SessionID]time.Time), | ||
| 264 | lastContext: make(map[domain.SessionID]barScriptContext), | 272 | lastContext: make(map[domain.SessionID]barScriptContext), | ||