detached 83b5fe2

test(daemon): cover bar poller re-arm and interval change

barScriptPoller had zero test coverage and was only reachable via
Serve, so a rewrite that broke re-arming (e.g. blocking forever after
the first tick) would silently stop status-bar refreshes in
production while the suite stayed green.

Drive the poller directly with a fake clock that records timer
durations, and assert: a tick re-arms a new timer at the same
interval, a reload re-arms at the newly configured interval, and
context cancellation exits the goroutine.
commit
83b5fe2ef8a8dfe0de09fcd7d2ad8e9b2a904866
author
brice · · 2026-07-20 08:29 UTC
parent
747b112
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +101 -0
M internal/usecase/daemon/bar_refresh_test.go +101 -0
@@ -434,6 +434,107 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) {
434434 require.Equal(t, "bottom-b", stateB.bottomRight)
435435 }
436436
437+// pollerFakeTimer is a manually-fired timer for barScriptPoller tests. Its
438+// channel is buffered so the test goroutine can push a tick before the
439+// poller reaches its select without blocking.
440+type pollerFakeTimer struct {
441+ ch chan time.Time
442+}
443+
444+func (t *pollerFakeTimer) C() <-chan time.Time { return t.ch }
445+func (t *pollerFakeTimer) Reset(time.Duration) bool { return true }
446+func (t *pollerFakeTimer) Stop() bool { return true }
447+
448+// pollerFakeClock records every timer barScriptPoller creates, along with the
449+// duration requested, so a test can assert on re-arming behavior without real
450+// sleeps or waiting out domain.MinBarInterval. NewTimer runs on the poller
451+// goroutine while the test goroutine reads via count/at, so all access is
452+// mutex-guarded; nothing here calls testify from the poller goroutine.
453+type pollerFakeClock struct {
454+ mu sync.Mutex
455+ timers []*pollerFakeTimer
456+ durs []time.Duration
457+}
458+
459+func (c *pollerFakeClock) Now() time.Time { return time.Unix(0, 0) }
460+
461+func (c *pollerFakeClock) NewTimer(d time.Duration) ports.Timer {
462+ c.mu.Lock()
463+ defer c.mu.Unlock()
464+ t := &pollerFakeTimer{ch: make(chan time.Time, 1)}
465+ c.timers = append(c.timers, t)
466+ c.durs = append(c.durs, d)
467+ return t
468+}
469+
470+func (c *pollerFakeClock) count() int {
471+ c.mu.Lock()
472+ defer c.mu.Unlock()
473+ return len(c.timers)
474+}
475+
476+func (c *pollerFakeClock) at(i int) (*pollerFakeTimer, time.Duration) {
477+ c.mu.Lock()
478+ defer c.mu.Unlock()
479+ return c.timers[i], c.durs[i]
480+}
481+
482+// TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel drives barScriptPoller
483+// directly (not through Serve) with a fake 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.
491+func TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel(t *testing.T) {
492+ clk := &pollerFakeClock{}
493+ d := newBarRefreshTestDaemon(&fakeBarRunner{}, 2*time.Second)
494+ d.clock = clk
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+ require.Eventually(t, func() bool { return clk.count() >= 1 }, time.Second, time.Millisecond)
506+ _, dur0 := clk.at(0)
507+ require.Equal(t, 2*time.Second, dur0)
508+
509+ // Stage 2: firing that timer processes the tick and re-arms a new one.
510+ timer0, _ := clk.at(0)
511+ timer0.ch <- time.Unix(0, 0)
512+ require.Eventually(t, func() bool { return clk.count() >= 2 }, time.Second, time.Millisecond)
513+ _, dur1 := clk.at(1)
514+ require.Equal(t, 2*time.Second, dur1, "tick re-arm should keep the same interval when config is unchanged")
515+
516+ // Stage 3: changing the interval and signaling reload re-arms at the NEW
517+ // interval instead of waiting out the timer armed under the old one.
518+ d.barScripts.mu.Lock()
519+ d.barScripts.cfg.interval = 7 * time.Second
520+ d.barScripts.mu.Unlock()
521+ d.barScripts.reload <- struct{}{}
522+ require.Eventually(t, func() bool { return clk.count() >= 3 }, time.Second, time.Millisecond)
523+ _, dur2 := clk.at(2)
524+ require.Equal(t, 7*time.Second, dur2, "reload should re-arm at the newly configured interval")
525+
526+ // Stage 4: cancelling the context exits the goroutine instead of leaking it.
527+ cancel()
528+ require.Eventually(t, func() bool {
529+ select {
530+ case <-done:
531+ return true
532+ default:
533+ return false
534+ }
535+ }, time.Second, time.Millisecond, "barScriptPoller should exit promptly after context cancellation")
536+}
537+
437538 func TestApplyConfigSignalsPollerReload(t *testing.T) {
438539 r := &fakeBarRunner{}
439540 d := newBarRefreshTestDaemon(r, 60*time.Second)
@@ -434,6 +434,107 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) {
434 require.Equal(t, "bottom-b", stateB.bottomRight)434 require.Equal(t, "bottom-b", stateB.bottomRight)
435 }435 }
436 436
437+// pollerFakeTimer is a manually-fired timer for barScriptPoller tests. Its
438+// channel is buffered so the test goroutine can push a tick before the
439+// poller reaches its select without blocking.
440+type pollerFakeTimer struct {
441+ ch chan time.Time
442+}
443+
444+func (t *pollerFakeTimer) C() <-chan time.Time { return t.ch }
445+func (t *pollerFakeTimer) Reset(time.Duration) bool { return true }
446+func (t *pollerFakeTimer) Stop() bool { return true }
447+
448+// pollerFakeClock records every timer barScriptPoller creates, along with the
449+// duration requested, so a test can assert on re-arming behavior without real
450+// sleeps or waiting out domain.MinBarInterval. NewTimer runs on the poller
451+// goroutine while the test goroutine reads via count/at, so all access is
452+// mutex-guarded; nothing here calls testify from the poller goroutine.
453+type pollerFakeClock struct {
454+ mu sync.Mutex
455+ timers []*pollerFakeTimer
456+ durs []time.Duration
457+}
458+
459+func (c *pollerFakeClock) Now() time.Time { return time.Unix(0, 0) }
460+
461+func (c *pollerFakeClock) NewTimer(d time.Duration) ports.Timer {
462+ c.mu.Lock()
463+ defer c.mu.Unlock()
464+ t := &pollerFakeTimer{ch: make(chan time.Time, 1)}
465+ c.timers = append(c.timers, t)
466+ c.durs = append(c.durs, d)
467+ return t
468+}
469+
470+func (c *pollerFakeClock) count() int {
471+ c.mu.Lock()
472+ defer c.mu.Unlock()
473+ return len(c.timers)
474+}
475+
476+func (c *pollerFakeClock) at(i int) (*pollerFakeTimer, time.Duration) {
477+ c.mu.Lock()
478+ defer c.mu.Unlock()
479+ return c.timers[i], c.durs[i]
480+}
481+
482+// TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel drives barScriptPoller
483+// directly (not through Serve) with a fake 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.
491+func TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel(t *testing.T) {
492+ clk := &pollerFakeClock{}
493+ d := newBarRefreshTestDaemon(&fakeBarRunner{}, 2*time.Second)
494+ d.clock = clk
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+ require.Eventually(t, func() bool { return clk.count() >= 1 }, time.Second, time.Millisecond)
506+ _, dur0 := clk.at(0)
507+ require.Equal(t, 2*time.Second, dur0)
508+
509+ // Stage 2: firing that timer processes the tick and re-arms a new one.
510+ timer0, _ := clk.at(0)
511+ timer0.ch <- time.Unix(0, 0)
512+ require.Eventually(t, func() bool { return clk.count() >= 2 }, time.Second, time.Millisecond)
513+ _, dur1 := clk.at(1)
514+ require.Equal(t, 2*time.Second, dur1, "tick re-arm should keep the same interval when config is unchanged")
515+
516+ // Stage 3: changing the interval and signaling reload re-arms at the NEW
517+ // interval instead of waiting out the timer armed under the old one.
518+ d.barScripts.mu.Lock()
519+ d.barScripts.cfg.interval = 7 * time.Second
520+ d.barScripts.mu.Unlock()
521+ d.barScripts.reload <- struct{}{}
522+ require.Eventually(t, func() bool { return clk.count() >= 3 }, time.Second, time.Millisecond)
523+ _, dur2 := clk.at(2)
524+ require.Equal(t, 7*time.Second, dur2, "reload should re-arm at the newly configured interval")
525+
526+ // Stage 4: cancelling the context exits the goroutine instead of leaking it.
527+ cancel()
528+ require.Eventually(t, func() bool {
529+ select {
530+ case <-done:
531+ return true
532+ default:
533+ return false
534+ }
535+ }, time.Second, time.Millisecond, "barScriptPoller should exit promptly after context cancellation")
536+}
537+
437 func TestApplyConfigSignalsPollerReload(t *testing.T) {538 func TestApplyConfigSignalsPollerReload(t *testing.T) {
438 r := &fakeBarRunner{}539 r := &fakeBarRunner{}
439 d := newBarRefreshTestDaemon(r, 60*time.Second)540 d := newBarRefreshTestDaemon(r, 60*time.Second)

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close