Skip to content

Commit 954ffc3

Browse files
authored
Fix flaky TestHuhPrompterMultiSelectWithSearchPersistence on slow architectures (cli#13675)
1 parent 561d9f9 commit 954ffc3

2 files changed

Lines changed: 69 additions & 12 deletions

File tree

internal/prompter/huh_prompter_test.go

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package prompter
22

33
import (
44
"io"
5+
"sync"
56
"testing"
67
"time"
78

@@ -18,8 +19,9 @@ import (
1819
// bubbletea event loop.
1920

2021
type interactionStep struct {
21-
bytes []byte
22-
delay time.Duration // pause before sending (lets the event loop settle)
22+
bytes []byte
23+
delay time.Duration // pause before sending (lets the event loop settle)
24+
waitFn func() // if non-nil, called instead of time.Sleep(delay)
2325
}
2426

2527
type interaction struct {
@@ -33,9 +35,15 @@ func newInteraction(steps ...interactionStep) interaction {
3335
func (ix interaction) run(t *testing.T, w *io.PipeWriter) {
3436
t.Helper()
3537
for _, s := range ix.steps {
36-
time.Sleep(s.delay)
37-
_, err := w.Write(s.bytes)
38-
require.NoError(t, err)
38+
if s.waitFn != nil {
39+
s.waitFn()
40+
} else {
41+
time.Sleep(s.delay)
42+
}
43+
if s.bytes != nil {
44+
_, err := w.Write(s.bytes)
45+
require.NoError(t, err)
46+
}
3947
}
4048
}
4149

@@ -98,11 +106,45 @@ func clearLine() interactionStep {
98106
return interactionStep{bytes: []byte{0x01, 0x0b}}
99107
}
100108

101-
// waitForOptions adds extra delay to let OptionsFunc load before continuing.
109+
// waitForOptions adds extra delay to let the bubbletea event loop settle
110+
// after switching modes when no async search is triggered.
102111
func waitForOptions() interactionStep {
103112
return interactionStep{bytes: nil, delay: 50 * time.Millisecond}
104113
}
105114

115+
// waitForSearch returns an interactionStep that blocks until the field's
116+
// current async search completes. It wires a one-shot callback into the
117+
// field's onSearchDone hook and waits for it to fire, avoiding fixed-duration
118+
// sleeps that are too short on slow architectures such as s390x under QEMU.
119+
//
120+
// The wait is bounded by the test's deadline (from -timeout) so a hung search
121+
// fails the test with a clear message rather than blocking the whole test run.
122+
func waitForSearch(t *testing.T, field *multiSelectSearchField) interactionStep {
123+
t.Helper()
124+
done := make(chan struct{})
125+
var once sync.Once
126+
field.onSearchDone.Store(func() {
127+
once.Do(func() { close(done) })
128+
})
129+
130+
var timeout <-chan time.Time
131+
if deadline, ok := t.Deadline(); ok {
132+
timeout = time.After(time.Until(deadline))
133+
} else {
134+
timeout = time.After(30 * time.Second)
135+
}
136+
137+
return interactionStep{
138+
waitFn: func() {
139+
select {
140+
case <-done:
141+
case <-timeout:
142+
t.Fatal("timed out waiting for async search to complete")
143+
}
144+
},
145+
}
146+
}
147+
106148
// --- Test harness ---
107149

108150
func newTestHuhPrompter() *huhPrompter {
@@ -475,13 +517,16 @@ func TestHuhPrompterMultiSelectWithSearchPersistence(t *testing.T) {
475517
f, result := p.buildMultiSelectWithSearchForm(
476518
"Select", "Search", nil, nil, staticSearchFunc,
477519
)
520+
// waitForSearch must be created before runForm so the hook is in place
521+
// before the async search fires.
522+
searchDone := waitForSearch(t, result)
478523
runForm(t, f, newInteraction(
479-
tab(), waitForOptions(),
480-
toggle(), // toggle result-a
481-
shiftTab(), // back to search input
482-
typeKeys("foo"), // change query
483-
tab(), waitForOptions(),
484-
enter(), // submit — result-a should persist
524+
tab(), waitForOptions(), // switch to select mode (no async search)
525+
toggle(), // toggle result-a
526+
shiftTab(), // back to search input
527+
typeKeys("foo"), // change query
528+
tab(), searchDone, // submit query → async search; wait for completion
529+
enter(), // submit form — guaranteed search is done
485530
))
486531
assert.Equal(t, []string{"result-a"}, result.selectedKeys())
487532
})

internal/prompter/multi_select_with_search.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"io"
66
"strings"
7+
"sync/atomic"
78

89
"charm.land/bubbles/v2/key"
910
"charm.land/bubbles/v2/spinner"
@@ -47,6 +48,13 @@ type multiSelectSearchField struct {
4748
theme huh.Theme
4849
hasDarkBg bool
4950
position huh.FieldPosition
51+
52+
// onSearchDone stores a func() that is called each time an async search
53+
// completes. It is unset in production and used only in tests to
54+
// synchronize on search completion without relying on fixed-duration
55+
// sleeps. atomic.Value is used because the hook is written by the test
56+
// goroutine and invoked by bubbletea's event-loop goroutine.
57+
onSearchDone atomic.Value
5058
}
5159

5260
type msMode int
@@ -170,6 +178,10 @@ func (m *multiSelectSearchField) applySearchResult(query string, result MultiSel
170178
m.options = options
171179
m.cursor = 0
172180
m.err = nil
181+
182+
if hook, ok := m.onSearchDone.Load().(func()); ok {
183+
hook()
184+
}
173185
}
174186

175187
func (m *multiSelectSearchField) selectedKeys() []string {

0 commit comments

Comments
 (0)