bash_background_test.go

  1package tools
  2
  3import (
  4	"context"
  5	"strings"
  6	"testing"
  7	"time"
  8
  9	"github.com/charmbracelet/crush/internal/shell"
 10	"github.com/stretchr/testify/require"
 11)
 12
 13func TestBackgroundShell_Integration(t *testing.T) {
 14	t.Parallel()
 15
 16	workingDir := t.TempDir()
 17	ctx := context.Background()
 18
 19	// Start a background shell
 20	bgManager := shell.GetBackgroundShellManager()
 21	bgShell, err := bgManager.Start(ctx, workingDir, nil, "echo 'hello background' && echo 'done'")
 22	require.NoError(t, err)
 23	require.NotEmpty(t, bgShell.ID)
 24
 25	// Wait for completion
 26	bgShell.Wait()
 27
 28	// Check final output
 29	stdout, stderr, done, err := bgShell.GetOutput()
 30	require.NoError(t, err)
 31	require.Contains(t, stdout, "hello background")
 32	require.Contains(t, stdout, "done")
 33	require.True(t, done)
 34	require.Empty(t, stderr)
 35
 36	// Clean up
 37	bgManager.Kill(bgShell.ID)
 38}
 39
 40func TestBackgroundShell_Kill(t *testing.T) {
 41	t.Parallel()
 42
 43	workingDir := t.TempDir()
 44	ctx := context.Background()
 45
 46	// Start a long-running background shell
 47	bgManager := shell.GetBackgroundShellManager()
 48	bgShell, err := bgManager.Start(ctx, workingDir, nil, "sleep 100")
 49	require.NoError(t, err)
 50
 51	// Kill it
 52	err = bgManager.Kill(bgShell.ID)
 53	require.NoError(t, err)
 54
 55	// Verify it's gone
 56	_, ok := bgManager.Get(bgShell.ID)
 57	require.False(t, ok)
 58
 59	// Verify the shell is done
 60	require.True(t, bgShell.IsDone())
 61}
 62
 63func TestBackgroundShell_GetOutput_NoHang(t *testing.T) {
 64	t.Parallel()
 65
 66	workingDir := t.TempDir()
 67	ctx := context.Background()
 68
 69	// Start a long-running background shell
 70	bgManager := shell.GetBackgroundShellManager()
 71	bgShell, err := bgManager.Start(ctx, workingDir, nil, "sleep 10")
 72	require.NoError(t, err)
 73	defer bgManager.Kill(bgShell.ID)
 74
 75	// This should complete quickly without hanging
 76	done := make(chan struct{})
 77	go func() {
 78		_, _, _, err := bgShell.GetOutput()
 79		require.NoError(t, err)
 80		close(done)
 81	}()
 82
 83	select {
 84	case <-done:
 85		// Success - didn't hang
 86	case <-time.After(2 * time.Second):
 87		t.Fatal("GetOutput() hung - did not complete within timeout")
 88	}
 89}
 90
 91func TestBackgroundShell_MultipleOutputCalls(t *testing.T) {
 92	t.Parallel()
 93
 94	workingDir := t.TempDir()
 95	ctx := context.Background()
 96
 97	// Start a background shell
 98	bgManager := shell.GetBackgroundShellManager()
 99	bgShell, err := bgManager.Start(ctx, workingDir, nil, "echo 'step 1' && echo 'step 2' && echo 'step 3'")
100	require.NoError(t, err)
101	defer bgManager.Kill(bgShell.ID)
102
103	// Check that we can call GetOutput multiple times while running
104	for range 5 {
105		_, _, done, _ := bgShell.GetOutput()
106		if done {
107			break
108		}
109		time.Sleep(10 * time.Millisecond)
110	}
111
112	// Wait for completion
113	bgShell.Wait()
114
115	// Multiple calls after completion should return the same result
116	stdout1, _, done1, _ := bgShell.GetOutput()
117	require.True(t, done1)
118	require.Contains(t, stdout1, "step 1")
119	require.Contains(t, stdout1, "step 2")
120	require.Contains(t, stdout1, "step 3")
121
122	stdout2, _, done2, _ := bgShell.GetOutput()
123	require.True(t, done2)
124	require.Equal(t, stdout1, stdout2, "Multiple GetOutput calls should return same result")
125}
126
127func TestBackgroundShell_EmptyOutput(t *testing.T) {
128	t.Parallel()
129
130	workingDir := t.TempDir()
131	ctx := context.Background()
132
133	// Start a background shell with no output
134	bgManager := shell.GetBackgroundShellManager()
135	bgShell, err := bgManager.Start(ctx, workingDir, nil, "sleep 0.1")
136	require.NoError(t, err)
137	defer bgManager.Kill(bgShell.ID)
138
139	// Wait for completion
140	bgShell.Wait()
141
142	stdout, stderr, done, err := bgShell.GetOutput()
143	require.NoError(t, err)
144	require.Empty(t, stdout)
145	require.Empty(t, stderr)
146	require.True(t, done)
147}
148
149func TestBackgroundShell_ExitCode(t *testing.T) {
150	t.Parallel()
151
152	workingDir := t.TempDir()
153	ctx := context.Background()
154
155	// Start a background shell that exits with non-zero code
156	bgManager := shell.GetBackgroundShellManager()
157	bgShell, err := bgManager.Start(ctx, workingDir, nil, "echo 'failing' && exit 42")
158	require.NoError(t, err)
159	defer bgManager.Kill(bgShell.ID)
160
161	// Wait for completion
162	bgShell.Wait()
163
164	stdout, _, done, execErr := bgShell.GetOutput()
165	require.True(t, done)
166	require.Contains(t, stdout, "failing")
167	require.Error(t, execErr)
168
169	exitCode := shell.ExitCode(execErr)
170	require.Equal(t, 42, exitCode)
171}
172
173func TestBackgroundShell_WithBlockFuncs(t *testing.T) {
174	t.Parallel()
175
176	workingDir := t.TempDir()
177	ctx := context.Background()
178
179	blockFuncs := []shell.BlockFunc{
180		shell.CommandsBlocker([]string{"curl", "wget"}),
181	}
182
183	// Start a background shell with a blocked command
184	bgManager := shell.GetBackgroundShellManager()
185	bgShell, err := bgManager.Start(ctx, workingDir, blockFuncs, "curl example.com")
186	require.NoError(t, err)
187	defer bgManager.Kill(bgShell.ID)
188
189	// Wait for completion
190	bgShell.Wait()
191
192	stdout, stderr, done, execErr := bgShell.GetOutput()
193	require.True(t, done)
194
195	// The command should have been blocked, check stderr or error
196	if execErr != nil {
197		// Error might contain the message
198		require.Contains(t, execErr.Error(), "not allowed")
199	} else {
200		// Or it might be in stderr
201		output := stdout + stderr
202		require.Contains(t, output, "not allowed")
203	}
204}
205
206func TestBackgroundShell_StdoutAndStderr(t *testing.T) {
207	t.Parallel()
208
209	workingDir := t.TempDir()
210	ctx := context.Background()
211
212	// Start a background shell with both stdout and stderr
213	bgManager := shell.GetBackgroundShellManager()
214	bgShell, err := bgManager.Start(ctx, workingDir, nil, "echo 'stdout message' && echo 'stderr message' >&2")
215	require.NoError(t, err)
216	defer bgManager.Kill(bgShell.ID)
217
218	// Wait for completion
219	bgShell.Wait()
220
221	stdout, stderr, done, err := bgShell.GetOutput()
222	require.NoError(t, err)
223	require.True(t, done)
224	require.Contains(t, stdout, "stdout message")
225	require.Contains(t, stderr, "stderr message")
226}
227
228func TestBackgroundShell_ConcurrentAccess(t *testing.T) {
229	t.Parallel()
230
231	workingDir := t.TempDir()
232	ctx := context.Background()
233
234	// Start a background shell
235	bgManager := shell.GetBackgroundShellManager()
236	bgShell, err := bgManager.Start(ctx, workingDir, nil, "for i in 1 2 3 4 5; do echo \"line $i\"; sleep 0.05; done")
237	require.NoError(t, err)
238	defer bgManager.Kill(bgShell.ID)
239
240	// Access output concurrently from multiple goroutines
241	done := make(chan struct{})
242	errors := make(chan error, 10)
243
244	for range 10 {
245		go func() {
246			for {
247				select {
248				case <-done:
249					return
250				default:
251					_, _, _, err := bgShell.GetOutput()
252					if err != nil {
253						errors <- err
254					}
255					dir := bgShell.GetWorkingDir()
256					if dir == "" {
257						errors <- err
258					}
259					time.Sleep(10 * time.Millisecond)
260				}
261			}
262		}()
263	}
264
265	// Let it run for a bit
266	time.Sleep(300 * time.Millisecond)
267	close(done)
268
269	// Check for any errors
270	select {
271	case err := <-errors:
272		t.Fatalf("Concurrent access caused error: %v", err)
273	case <-time.After(100 * time.Millisecond):
274		// No errors - success
275	}
276}
277
278func TestBackgroundShell_List(t *testing.T) {
279	t.Parallel()
280
281	workingDir := t.TempDir()
282	ctx := context.Background()
283
284	bgManager := shell.GetBackgroundShellManager()
285
286	// Start multiple background shells
287	shells := make([]*shell.BackgroundShell, 3)
288	for i := range 3 {
289		bgShell, err := bgManager.Start(ctx, workingDir, nil, "sleep 1")
290		require.NoError(t, err)
291		shells[i] = bgShell
292	}
293
294	// Get the list
295	ids := bgManager.List()
296
297	// Verify all our shells are in the list
298	for _, sh := range shells {
299		require.Contains(t, ids, sh.ID, "Shell %s not found in list", sh.ID)
300	}
301
302	// Clean up
303	for _, sh := range shells {
304		bgManager.Kill(sh.ID)
305	}
306}
307
308func TestBackgroundShell_IDFormat(t *testing.T) {
309	t.Parallel()
310
311	workingDir := t.TempDir()
312	ctx := context.Background()
313
314	bgManager := shell.GetBackgroundShellManager()
315	bgShell, err := bgManager.Start(ctx, workingDir, nil, "echo 'test'")
316	require.NoError(t, err)
317	defer bgManager.Kill(bgShell.ID)
318
319	// Verify ID is human-readable (hotdiva2000 format)
320	// Should contain hyphens and be readable
321	require.NotEmpty(t, bgShell.ID)
322	require.Contains(t, bgShell.ID, "-", "ID should be human-readable with hyphens")
323
324	// Should not be a UUID format
325	require.False(t, strings.Contains(bgShell.ID, "uuid"), "ID should not be UUID format")
326
327	// Length should be reasonable for human-readable IDs
328	require.Greater(t, len(bgShell.ID), 5, "ID should be long enough")
329	require.Less(t, len(bgShell.ID), 100, "ID should not be too long")
330}
331
332func TestBackgroundShell_AutoBackground(t *testing.T) {
333	t.Parallel()
334
335	workingDir := t.TempDir()
336	ctx := context.Background()
337
338	// Test that a quick command completes synchronously
339	t.Run("quick command completes synchronously", func(t *testing.T) {
340		t.Parallel()
341		bgManager := shell.GetBackgroundShellManager()
342		bgShell, err := bgManager.Start(ctx, workingDir, nil, "echo 'quick'")
343		require.NoError(t, err)
344
345		// Wait threshold time
346		time.Sleep(5 * time.Second)
347
348		// Should be done by now
349		stdout, stderr, done, err := bgShell.GetOutput()
350		require.NoError(t, err)
351		require.True(t, done, "Quick command should be done")
352		require.Contains(t, stdout, "quick")
353		require.Empty(t, stderr)
354
355		// Clean up
356		bgManager.Kill(bgShell.ID)
357	})
358
359	// Test that a long command stays in background
360	t.Run("long command stays in background", func(t *testing.T) {
361		t.Parallel()
362		bgManager := shell.GetBackgroundShellManager()
363		bgShell, err := bgManager.Start(ctx, workingDir, nil, "sleep 20 && echo '20 seconds completed'")
364		require.NoError(t, err)
365		defer bgManager.Kill(bgShell.ID)
366
367		// Wait threshold time
368		time.Sleep(5 * time.Second)
369
370		// Should still be running
371		stdout, stderr, done, err := bgShell.GetOutput()
372		require.NoError(t, err)
373		require.False(t, done, "Long command should still be running")
374		require.Empty(t, stdout, "No output yet from sleep command")
375		require.Empty(t, stderr)
376
377		// Verify we can get the shell from manager
378		retrieved, ok := bgManager.Get(bgShell.ID)
379		require.True(t, ok, "Should be able to retrieve background shell")
380		require.Equal(t, bgShell.ID, retrieved.ID)
381	})
382
383	// Test that we can check output of long-running command later
384	t.Run("can check output after completion", func(t *testing.T) {
385		t.Parallel()
386		bgManager := shell.GetBackgroundShellManager()
387		bgShell, err := bgManager.Start(ctx, workingDir, nil, "sleep 3 && echo 'completed'")
388		require.NoError(t, err)
389		defer bgManager.Kill(bgShell.ID)
390
391		// Initially should be running
392		_, _, done, _ := bgShell.GetOutput()
393		require.False(t, done, "Should be running initially")
394
395		// Wait for completion
396		time.Sleep(4 * time.Second)
397
398		// Now should be done
399		stdout, stderr, done, err := bgShell.GetOutput()
400		require.NoError(t, err)
401		require.True(t, done, "Should be done after waiting")
402		require.Contains(t, stdout, "completed")
403		require.Empty(t, stderr)
404	})
405}