1package daemon
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "log/slog"
8 "strings"
9 "sync"
10 "testing"
11 "time"
12
13 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/domain/layout"
15 "github.com/bnema/vev/internal/ports"
16 portsmocks "github.com/bnema/vev/internal/ports/mocks"
17 "github.com/stretchr/testify/mock"
18 "github.com/stretchr/testify/require"
19)
20
21type fakeBarRunner struct {
22 mu sync.Mutex
23 calls []barScriptContext
24 outs []string
25 errs []error
26}
27
28func (r *fakeBarRunner) run(_ context.Context, _ string, _ []string, ctx barScriptContext) (string, error) {
29 r.mu.Lock()
30 defer r.mu.Unlock()
31 r.calls = append(r.calls, ctx)
32 i := len(r.calls) - 1
33 if i < len(r.errs) && r.errs[i] != nil {
34 return "", r.errs[i]
35 }
36 if i < len(r.outs) {
37 return r.outs[i], nil
38 }
39 return "", nil
40}
41
42type blockingBarRunner struct {
43 *fakeBarRunner
44 entered chan struct{}
45 release chan struct{}
46 once sync.Once
47}
48
49func newBlockingBarRunner(outs []string) *blockingBarRunner {
50 return &blockingBarRunner{fakeBarRunner: &fakeBarRunner{outs: outs}, entered: make(chan struct{}), release: make(chan struct{})}
51}
52
53func (r *blockingBarRunner) run(ctx context.Context, command string, env []string, barCtx barScriptContext) (string, error) {
54 r.once.Do(func() { close(r.entered) })
55 select {
56 case <-r.release:
57 case <-ctx.Done():
58 return "", ctx.Err()
59 }
60 return r.fakeBarRunner.run(ctx, command, env, barCtx)
61}
62
63func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) {
64 r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}}
65 d := newBarRefreshTestDaemon(r, 100*time.Millisecond)
66 sess := newBarRefreshTestSession()
67 sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 120, Rows: 24}}})
68
69 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
70 waitBarRefreshIdle(t, d)
71 require.Equal(t, 2, len(r.calls))
72 require.Equal(t, "top1", d.barStateFor(sess, "").topRight)
73 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, int64(500*time.Millisecond)), false), "clamped interval should skip sub-second tick")
74
75 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false))
76 waitBarRefreshIdle(t, d)
77 require.Equal(t, 4, len(r.calls))
78 require.Equal(t, "top2", d.barStateFor(sess, "").topRight)
79
80 r.errs = []error{nil, nil, nil, nil, errors.New("boom"), errors.New("boom")}
81 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(2, 0), false))
82 waitBarRefreshIdle(t, d)
83 state := d.barStateFor(sess, "")
84 require.Equal(t, "top2", state.topRight)
85 require.Equal(t, "bot2", state.bottomRight)
86}
87
88func TestBarScriptContextUsesFirstOrderedPaneAndClientCols(t *testing.T) {
89 r := &fakeBarRunner{outs: []string{"top", "bottom"}}
90 d := newBarRefreshTestDaemon(r, time.Second)
91 sess := newBarRefreshTestSession()
92 sess.name = "work"
93 sess.cwd = "/repo"
94 sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 132, Rows: 40}}})
95 sess.tabs[0].size = domain.Size{Cols: 132, Rows: 38}
96 active := sess.tabs[0].focusedPane()
97 active.stableID = "pane-default"
98 active.pty = newScriptPTY(nil)
99 d.procCwd = func(pid int) (string, error) {
100 require.Equal(t, 4242, pid)
101 return "/pane-repo", nil
102 }
103
104 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
105 waitBarRefreshIdle(t, d)
106 require.Len(t, r.calls, 2)
107 for _, call := range r.calls {
108 require.Equal(t, "work", call.Session)
109 require.Equal(t, "tab-inactive", call.Tab)
110 require.Equal(t, "pane-default", call.Pane)
111 require.Equal(t, "/pane-repo", call.PaneCWD)
112 require.Equal(t, 132, call.Cols)
113 }
114 require.Equal(t, "top-right", r.calls[0].Anchor)
115 require.Equal(t, "bottom-right", r.calls[1].Anchor)
116}
117
118func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) {
119 r := &fakeBarRunner{}
120 d := newBarRefreshTestDaemon(r, time.Second)
121 sess := newBarRefreshTestSession()
122
123 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
124 require.Empty(t, r.calls)
125}
126
127func TestBarScriptRefreshSkipsRunnerWhenCommandsDisabled(t *testing.T) {
128 r := &fakeBarRunner{}
129 d := newBarRefreshTestDaemon(r, time.Second)
130 d.barScripts.cfg.topRight = ""
131 d.barScripts.cfg.bottomRight = ""
132 sess := newBarRefreshTestSession()
133 sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
134 d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "old", bottomRight: "old"}
135
136 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
137 require.Empty(t, r.calls)
138 state := d.barStateFor(sess, "")
139 require.Empty(t, state.topRight)
140 require.Empty(t, state.bottomRight)
141}
142
143func TestDefaultBarConfigDoesNotInvokeRunner(t *testing.T) {
144 r := &fakeBarRunner{}
145 d := newBarRefreshTestDaemon(r, time.Second)
146 d.barScripts.cfg = barConfigFromDomain(domain.Defaults().Bar)
147 sess := newBarRefreshTestSession()
148 sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
149
150 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
151 require.Empty(t, r.calls)
152}
153
154func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) {
155 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
156 d := newBarRefreshTestDaemon(r, 5*time.Second)
157 sess := newBarRefreshTestSession()
158 sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
159
160 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
161 waitBarRefreshIdle(t, d)
162 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, int64(500*time.Millisecond)), true))
163 require.Len(t, r.calls, 2)
164 require.Eventually(t, func() bool {
165 r.mu.Lock()
166 defer r.mu.Unlock()
167 return len(r.calls) == 4
168 }, 1500*time.Millisecond, 10*time.Millisecond)
169}
170
171func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) {
172 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
173 d := newBarRefreshTestDaemon(r, 5*time.Second)
174 sess := newBarRefreshTestSession()
175 sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
176
177 require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false))
178 waitBarRefreshIdle(t, d)
179 sess.cwd = "/other"
180 require.False(t, d.refreshBarScriptsIfDue(sess, time.Now().Add(100*time.Millisecond), false))
181 require.Eventually(t, func() bool {
182 r.mu.Lock()
183 defer r.mu.Unlock()
184 return len(r.calls) == 4
185 }, 1500*time.Millisecond, 10*time.Millisecond)
186 require.Equal(t, "/other", r.calls[2].PaneCWD)
187}
188
189func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) {
190 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
191 d := newBarRefreshTestDaemon(r, 5*time.Second)
192 sess := newBarRefreshTestSession()
193 sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
194
195 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
196 waitBarRefreshIdle(t, d)
197 sess.cwd = "/other"
198 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false))
199 waitBarRefreshIdle(t, d)
200 require.Len(t, r.calls, 4)
201 require.Equal(t, "/other", r.calls[2].PaneCWD)
202}
203
204func TestApplyConfigPreservesBarOutputsWhenBarConfigUnchanged(t *testing.T) {
205 r := &fakeBarRunner{}
206 d := newBarRefreshTestDaemon(r, time.Second)
207 sess := newBarRefreshTestSession()
208 d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "top-good", bottomRight: "bottom-good"}
209
210 d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}})
211
212 state := d.barStateFor(sess, "")
213 require.Equal(t, "top-good", state.topRight)
214 require.Equal(t, "bottom-good", state.bottomRight)
215}
216
217func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) {
218 r := &fakeBarRunner{outs: []string{"top1", "bottom1"}}
219 d := newBarRefreshTestDaemon(r, 60*time.Second)
220 sess := newBarRefreshTestSession()
221 sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
222 d.sessions = map[domain.SessionID]*session{sess.id: sess}
223
224 // Verifies refreshBarScriptsAllSessions iterates every live session and
225 // dispatches a run for each. This does not exercise force specifically:
226 // lastRefresh is zero on a fresh session, so refreshBarScriptsIfDue skips
227 // the interval check regardless of force, and a non-forcing call would
228 // pass this test too.
229 d.refreshBarScriptsAllSessions()
230 waitBarRefreshIdle(t, d)
231
232 require.Len(t, r.calls, 2, "should force a run for every live session")
233 require.Equal(t, "top1", d.barStateFor(sess, "").topRight)
234}
235
236func TestApplyConfigBumpsVersionOnlyWhenBarConfigChanges(t *testing.T) {
237 r := &fakeBarRunner{}
238 d := newBarRefreshTestDaemon(r, time.Second)
239
240 before := d.barScripts.version
241 d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}})
242 require.Equal(t, before, d.barScripts.version, "identical bar config must not invalidate")
243
244 d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "changed", BottomRight: "bottom", Interval: time.Second}})
245 require.Equal(t, before+1, d.barScripts.version, "changed bar config must invalidate")
246}
247
248func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) {
249 var logs bytes.Buffer
250 r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}}
251 d := newBarRefreshTestDaemon(r, time.Second)
252 d.log = slog.New(slog.NewTextHandler(&logs, nil))
253 sess := newBarRefreshTestSession()
254 sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
255
256 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
257 waitBarRefreshIdle(t, d)
258 r.errs = []error{nil, nil, errors.New("boom"), errors.New("bang")}
259 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true))
260 waitBarRefreshIdle(t, d)
261
262 state := d.barStateFor(sess, "")
263 require.Equal(t, "top-good", state.topRight)
264 require.Equal(t, "bottom-good", state.bottomRight)
265 require.True(t, strings.Contains(logs.String(), "bar script failed; keeping last good output"), logs.String())
266 require.True(t, strings.Contains(logs.String(), "anchor=top-right"), logs.String())
267}
268
269func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) {
270 r := &fakeBarRunner{outs: []string{"top", "bottom"}}
271 d := newBarRefreshTestDaemon(r, time.Second)
272 sess := newBarRefreshTestSession()
273 d.barScripts.mu.Lock()
274 d.barScripts.running[sess.id] = true
275 d.barScripts.mu.Unlock()
276
277 d.clearBarScriptsForSession(sess.id)
278 d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, nil, 0)
279
280 state := d.barStateFor(sess, "")
281 require.Empty(t, state.topRight)
282 require.Empty(t, state.bottomRight)
283}
284
285func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) {
286 r := newBlockingBarRunner([]string{"top1", "bottom1", "top2", "bottom2"})
287 d := newBarRefreshTestDaemon(r, time.Second)
288 sess := newBarRefreshTestSession()
289 sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
290
291 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
292 select {
293 case <-r.entered:
294 case <-time.After(time.Second):
295 t.Fatal("timeout waiting for first bar script run")
296 }
297 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true))
298 close(r.release)
299 waitBarRefreshIdle(t, d)
300 require.Eventually(t, func() bool {
301 r.mu.Lock()
302 defer r.mu.Unlock()
303 return len(r.calls) == 4
304 }, time.Second, time.Millisecond)
305}
306
307// TestBarScriptFailureLogsOnceUntilSignatureChanges covers the full
308// shouldLogBarFailure/clearBarFailure contract for a single anchor
309// (top-right; bottom-right always succeeds in this test so it never
310// contributes a log line):
311//
312// 1. an identical repeated failure does not log a second time,
313// 2. a different failure for the same session+anchor logs again, and
314// 3. a successful run clears the recorded signature, so a subsequent
315// identical failure logs again afterward.
316//
317// Each refresh consumes two fakeBarRunner call-count entries (top-right then
318// bottom-right), so the stages are order-dependent and asserted sequentially
319// rather than table-driven.
320func TestBarScriptFailureLogsOnceUntilSignatureChanges(t *testing.T) {
321 var buf bytes.Buffer
322 var mu sync.Mutex
323 logger := slog.New(slog.NewTextHandler(&syncWriter{w: &buf, mu: &mu}, nil))
324
325 notFound := &barScriptError{exitCode: 127, stderr: "sh: vev-bar-top-right: not found", err: errors.New("exit status 127")}
326 permissionDenied := &barScriptError{exitCode: 126, stderr: "sh: vev-bar-top-right: permission denied", err: errors.New("exit status 126")}
327
328 r := &fakeBarRunner{errs: []error{
329 notFound, nil, // refresh 1: top fails
330 notFound, nil, // refresh 2: top fails identically -> must not log again
331 permissionDenied, nil, // refresh 3: top fails differently -> must log again
332 nil, nil, // refresh 4: top succeeds -> clears recorded signature
333 notFound, nil, // refresh 5: top fails with the original signature -> must log (cleared)
334 }}
335 d := newBarRefreshTestDaemon(r, time.Second)
336 d.log = logger
337 d.baseEnv = []string{"PATH=/usr/bin"}
338 sess := newBarRefreshTestSession()
339 sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
340
341 readBuf := func() string {
342 mu.Lock()
343 defer mu.Unlock()
344 return buf.String()
345 }
346
347 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
348 waitBarRefreshIdle(t, d)
349 out := readBuf()
350 require.Equal(t, 1, strings.Count(out, "anchor=top-right"), "first failure should log")
351 require.Contains(t, out, "not found")
352 require.Contains(t, out, "PATH=/usr/bin")
353
354 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
355 waitBarRefreshIdle(t, d)
356 out = readBuf()
357 require.Equal(t, 1, strings.Count(out, "anchor=top-right"), "identical repeated failure should not log again")
358
359 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(20, 0), true))
360 waitBarRefreshIdle(t, d)
361 out = readBuf()
362 require.Equal(t, 2, strings.Count(out, "anchor=top-right"), "a different failure for the same anchor should log again")
363 require.Contains(t, out, "permission denied")
364
365 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(30, 0), true))
366 waitBarRefreshIdle(t, d)
367 out = readBuf()
368 require.Equal(t, 2, strings.Count(out, "anchor=top-right"), "a successful run must not log and must clear the recorded signature")
369
370 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(40, 0), true))
371 waitBarRefreshIdle(t, d)
372 out = readBuf()
373 require.Equal(t, 3, strings.Count(out, "anchor=top-right"),
374 "an identical failure after a successful run should log again since the recorded signature was cleared")
375}
376
377type syncWriter struct {
378 w *bytes.Buffer
379 mu *sync.Mutex
380}
381
382func (s *syncWriter) Write(p []byte) (int, error) {
383 s.mu.Lock()
384 defer s.mu.Unlock()
385 return s.w.Write(p)
386}
387
388func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) {
389 r := &fakeBarRunner{outs: []string{"top", "bottom"}}
390 d := newBarRefreshTestDaemon(r, time.Second)
391 sess := newBarRefreshTestSession()
392 d.barScripts.mu.Lock()
393 d.barScripts.running[sess.id] = true
394 d.barScripts.version = 2
395 d.barScripts.mu.Unlock()
396
397 d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, nil, 1)
398
399 state := d.barStateFor(sess, "")
400 require.Empty(t, state.topRight)
401 require.Empty(t, state.bottomRight)
402}
403
404func TestPokeSessionRenderWakesHeadlessPreviewCoordinator(t *testing.T) {
405 d := newBarRefreshTestDaemon(nil, time.Second)
406 sess := newBarRefreshTestSession()
407 var invalidations int
408 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(renderInvalidation) { invalidations++ }})
409 sess.installRenderCoordinator(rc)
410
411 d.pokeSessionRender(sess)
412
413 require.Equal(t, 1, invalidations)
414}
415
416func TestBarScriptRefreshIsPerSession(t *testing.T) {
417 r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}}
418 d := newBarRefreshTestDaemon(r, time.Second)
419 a := newBarRefreshTestSession()
420 a.id = "a"
421 a.name = "a"
422 a.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
423 b := newBarRefreshTestSession()
424 b.id = "b"
425 b.name = "b"
426 b.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
427
428 require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false))
429 waitBarRefreshIdle(t, d)
430 require.True(t, d.refreshBarScriptsIfDue(b, time.Unix(0, 0), false))
431 waitBarRefreshIdle(t, d)
432
433 stateA := d.barStateFor(a, "")
434 stateB := d.barStateFor(b, "")
435 require.Equal(t, "top-a", stateA.topRight)
436 require.Equal(t, "bottom-a", stateA.bottomRight)
437 require.Equal(t, "top-b", stateB.topRight)
438 require.Equal(t, "bottom-b", stateB.bottomRight)
439}
440
441// pollerMockClock and pollerMockTimer wrap the generated portsmocks.MockClock
442// and portsmocks.MockTimer (see internal/ports/mocks/clock_mock.go), following
443// the coordinatorMockClock pattern in render_coordinator_test.go: the mocks
444// supply the ports.Clock/ports.Timer implementations, while a small
445// hand-owned struct retains the real channel and requested duration for each
446// timer so the test goroutine can fire ticks and assert on re-arming.
447type pollerMockClock struct {
448 clock *portsmocks.MockClock
449 timers chan *pollerMockTimer
450}
451
452type pollerMockTimer struct {
453 mock *portsmocks.MockTimer
454 ch chan time.Time
455 duration time.Duration
456}
457
458func newPollerMockClock(t *testing.T, capacity int) *pollerMockClock {
459 t.Helper()
460 clk := &pollerMockClock{
461 clock: portsmocks.NewMockClock(t),
462 timers: make(chan *pollerMockTimer, capacity),
463 }
464 clk.clock.EXPECT().Now().Return(time.Unix(0, 0)).Maybe()
465 clk.clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(d time.Duration) ports.Timer {
466 timer := &pollerMockTimer{
467 mock: portsmocks.NewMockTimer(t),
468 duration: d,
469 ch: make(chan time.Time, 1),
470 }
471 // Stop is called 0, 1, or 2 times per timer depending on whether it is
472 // replaced mid-loop or torn down by the poller's deferred Stop on
473 // exit, so its expectation is unconstrained.
474 timer.mock.EXPECT().C().Maybe().Return((<-chan time.Time)(timer.ch))
475 timer.mock.EXPECT().Stop().Maybe().Return(true)
476 clk.timers <- timer
477 return timer.mock
478 }).Maybe()
479 return clk
480}
481
482// TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel drives barScriptPoller
483// directly (not through Serve) with a mock clock, covering the three
484// behaviors the Task 5 rewrite touched but left untested:
485//
486// 1. after a tick fires, the loop arms a NEW timer instead of blocking
487// forever (the regression that would silently stop bar refreshes),
488// 2. a reload signal re-arms the timer at the newly configured interval
489// rather than the one the poller started with, and
490// 3. cancelling the context makes the goroutine return instead of leaking.
491func TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel(t *testing.T) {
492 clk := newPollerMockClock(t, 8)
493 d := newBarRefreshTestDaemon(&fakeBarRunner{}, 2*time.Second)
494 d.clock = clk.clock
495 d.barScripts.reload = make(chan struct{}, 1)
496
497 ctx, cancel := context.WithCancel(context.Background())
498 done := make(chan struct{})
499 go func() {
500 d.barScriptPoller(ctx)
501 close(done)
502 }()
503
504 // Stage 1: the poller arms its first timer at the configured interval.
505 var timer0 *pollerMockTimer
506 select {
507 case timer0 = <-clk.timers:
508 case <-time.After(time.Second):
509 t.Fatal("timed out waiting for the poller's initial timer")
510 }
511 require.Equal(t, 2*time.Second, timer0.duration)
512
513 // Stage 2: firing that timer processes the tick and re-arms a new one.
514 timer0.ch <- time.Unix(0, 0)
515 var timer1 *pollerMockTimer
516 select {
517 case timer1 = <-clk.timers:
518 case <-time.After(time.Second):
519 t.Fatal("timed out waiting for the poller to re-arm after a tick")
520 }
521 require.Equal(t, 2*time.Second, timer1.duration, "tick re-arm should keep the same interval when config is unchanged")
522
523 // Stage 3: changing the interval and signaling reload re-arms at the NEW
524 // interval instead of waiting out the timer armed under the old one.
525 d.barScripts.mu.Lock()
526 d.barScripts.cfg.interval = 7 * time.Second
527 d.barScripts.mu.Unlock()
528 d.barScripts.reload <- struct{}{}
529 var timer2 *pollerMockTimer
530 select {
531 case timer2 = <-clk.timers:
532 case <-time.After(time.Second):
533 t.Fatal("timed out waiting for the poller to re-arm after reload")
534 }
535 require.Equal(t, 7*time.Second, timer2.duration, "reload should re-arm at the newly configured interval")
536
537 // Stage 4: cancelling the context exits the goroutine instead of leaking it.
538 cancel()
539 select {
540 case <-done:
541 case <-time.After(time.Second):
542 t.Fatal("barScriptPoller did not exit after context cancellation")
543 }
544}
545
546func TestApplyConfigSignalsPollerReload(t *testing.T) {
547 r := &fakeBarRunner{}
548 d := newBarRefreshTestDaemon(r, 60*time.Second)
549 d.barScripts.reload = make(chan struct{}, 1)
550
551 cfg := domain.Defaults()
552 cfg.Bar.TopRight = "t"
553 cfg.Bar.BottomRight = "b"
554 cfg.Bar.Interval = time.Second
555 d.ApplyConfig(cfg)
556
557 select {
558 case <-d.barScripts.reload:
559 default:
560 t.Fatal("ApplyConfig should signal the poller to re-arm its timer")
561 }
562 require.Equal(t, time.Second, d.barScriptInterval())
563}
564
565func newBarRefreshTestDaemon(r barScriptExecutor, interval time.Duration) *Daemon {
566 d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{
567 cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)},
568 runner: r,
569 outputs: make(map[domain.SessionID]barScriptOutputs),
570 lastRefresh: make(map[domain.SessionID]time.Time),
571 lastContext: make(map[domain.SessionID]barScriptContext),
572 running: make(map[domain.SessionID]bool),
573 pending: make(map[domain.SessionID]bool),
574 }}
575 return d
576}
577
578type barRefreshTestClock struct{}
579
580func (barRefreshTestClock) Now() time.Time { return time.Now() }
581func (barRefreshTestClock) NewTimer(d time.Duration) ports.Timer {
582 return realTimer{t: time.NewTimer(d)}
583}
584
585func newBarRefreshTestSession() *session {
586 inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22})
587 active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22})
588 active.tree.Focus = layout.PaneID("pane-1")
589 return &session{sessionCore: sessionCore{id: "s", name: "s"}, cwd: "/tmp", tabs: []*tab{inactive, active}, ctx: context.Background()}
590}
591
592func waitBarRefreshIdle(t *testing.T, d *Daemon) {
593 t.Helper()
594 require.Eventually(t, func() bool {
595 d.barScripts.mu.Lock()
596 defer d.barScripts.mu.Unlock()
597 return len(d.barScripts.running) == 0
598 }, time.Second, time.Millisecond)
599}
600
601type envRecordingRunner struct {
602 mu sync.Mutex
603 envs [][]string
604}
605
606func (r *envRecordingRunner) run(_ context.Context, _ string, env []string, _ barScriptContext) (string, error) {
607 r.mu.Lock()
608 defer r.mu.Unlock()
609 r.envs = append(r.envs, append([]string(nil), env...))
610 return "out", nil
611}
612
613func (r *envRecordingRunner) captured() [][]string {
614 r.mu.Lock()
615 defer r.mu.Unlock()
616 return append([][]string(nil), r.envs...)
617}
618
619func TestBarScriptsUseSessionEnvNotDaemonBaseEnv(t *testing.T) {
620 tests := []struct {
621 name string
622 sessEnv []string
623 baseEnv []string
624 wantPath string
625 }{
626 {
627 name: "session env wins over stale daemon env",
628 sessEnv: []string{"PATH=/home/u/.local/bin:/usr/bin"},
629 baseEnv: []string{"PATH=/usr/bin"},
630 wantPath: "PATH=/home/u/.local/bin:/usr/bin",
631 },
632 {
633 name: "falls back to daemon env when session env empty",
634 sessEnv: nil,
635 baseEnv: []string{"PATH=/usr/bin"},
636 wantPath: "PATH=/usr/bin",
637 },
638 }
639
640 for _, tc := range tests {
641 t.Run(tc.name, func(t *testing.T) {
642 r := &envRecordingRunner{}
643 d := newBarRefreshTestDaemon(r, time.Second)
644 d.baseEnv = tc.baseEnv
645 sess := newBarRefreshTestSession()
646 sess.env = tc.sessEnv
647 sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
648
649 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
650 waitBarRefreshIdle(t, d)
651
652 envs := r.captured()
653 require.Len(t, envs, 2, "one call per anchor")
654 for _, env := range envs {
655 require.Contains(t, env, tc.wantPath)
656 }
657 })
658 }
659}