Skip to content
This repository was archived by the owner on Mar 12, 2026. It is now read-only.

Commit 4b6d2ff

Browse files
authored
Merge pull request #140 from nnemirovsky/fix/firstrun-wizard-nil-panic
fix: guard against nil list model panic in first-run wizard
2 parents 9d1a2ed + 195d372 commit 4b6d2ff

2 files changed

Lines changed: 340 additions & 6 deletions

File tree

internal/tui/firstrun.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -313,14 +313,18 @@ func (m FirstRunModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
313313
return m, cmd
314314

315315
case stepSelectAccount:
316-
var cmd tea.Cmd
317-
m.accountList, cmd = m.accountList.Update(msg)
318-
return m, cmd
316+
if len(m.accountList.Items()) > 0 {
317+
var cmd tea.Cmd
318+
m.accountList, cmd = m.accountList.Update(msg)
319+
return m, cmd
320+
}
319321

320322
case stepSelectProject:
321-
var cmd tea.Cmd
322-
m.projectList, cmd = m.projectList.Update(msg)
323-
return m, cmd
323+
if len(m.projectList.Items()) > 0 {
324+
var cmd tea.Cmd
325+
m.projectList, cmd = m.projectList.Update(msg)
326+
return m, cmd
327+
}
324328
}
325329

326330
return m, nil

internal/tui/firstrun_test.go

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
package tui
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/charmbracelet/bubbles/list"
8+
"github.com/charmbracelet/bubbles/spinner"
9+
tea "github.com/charmbracelet/bubbletea"
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func TestFirstRunModel_Update(t *testing.T) {
14+
t.Run("window resize", func(t *testing.T) {
15+
model := NewFirstRunModel()
16+
msg := tea.WindowSizeMsg{Width: 100, Height: 30}
17+
18+
newModel, _ := model.Update(msg)
19+
m := newModel.(FirstRunModel)
20+
21+
assert.Equal(t, 100, m.width)
22+
assert.Equal(t, 30, m.height)
23+
})
24+
25+
t.Run("ctrl+c quits", func(t *testing.T) {
26+
model := NewFirstRunModel()
27+
msg := tea.KeyMsg{Type: tea.KeyCtrlC}
28+
29+
_, cmd := model.Update(msg)
30+
31+
assert.NotNil(t, cmd)
32+
cmdMsg := cmd()
33+
_, isQuit := cmdMsg.(tea.QuitMsg)
34+
assert.True(t, isQuit)
35+
})
36+
37+
t.Run("escape quits from welcome", func(t *testing.T) {
38+
model := NewFirstRunModel()
39+
model.currentStep = stepWelcome
40+
msg := tea.KeyMsg{Type: tea.KeyEsc}
41+
42+
_, cmd := model.Update(msg)
43+
44+
assert.NotNil(t, cmd)
45+
cmdMsg := cmd()
46+
_, isQuit := cmdMsg.(tea.QuitMsg)
47+
assert.True(t, isQuit)
48+
})
49+
50+
t.Run("enter advances from welcome to clientID", func(t *testing.T) {
51+
model := NewFirstRunModel()
52+
model.currentStep = stepWelcome
53+
msg := tea.KeyMsg{Type: tea.KeyEnter}
54+
55+
newModel, _ := model.Update(msg)
56+
m := newModel.(FirstRunModel)
57+
58+
assert.Equal(t, stepClientID, m.currentStep)
59+
})
60+
61+
t.Run("spinner tick updates spinner", func(t *testing.T) {
62+
model := NewFirstRunModel()
63+
msg := spinner.TickMsg{Time: time.Now()}
64+
65+
_, cmd := model.Update(msg)
66+
67+
assert.NotNil(t, cmd)
68+
})
69+
}
70+
71+
func TestFirstRunModel_UninitializedListNoPanic(t *testing.T) {
72+
t.Run("stepSelectProject with uninitialized list does not panic on key msg", func(t *testing.T) {
73+
model := NewFirstRunModel()
74+
model.currentStep = stepSelectProject
75+
// projectList is zero-value (not initialized) - this is the bug scenario
76+
77+
msg := tea.KeyMsg{Type: tea.KeyDown}
78+
79+
assert.NotPanics(t, func() {
80+
model.Update(msg)
81+
})
82+
})
83+
84+
t.Run("stepSelectProject with uninitialized list does not panic on window resize", func(t *testing.T) {
85+
model := NewFirstRunModel()
86+
model.currentStep = stepSelectProject
87+
88+
msg := tea.WindowSizeMsg{Width: 80, Height: 24}
89+
90+
assert.NotPanics(t, func() {
91+
model.Update(msg)
92+
})
93+
})
94+
95+
t.Run("stepSelectProject with uninitialized list does not panic on spinner tick", func(t *testing.T) {
96+
model := NewFirstRunModel()
97+
model.currentStep = stepSelectProject
98+
99+
msg := spinner.TickMsg{Time: time.Now()}
100+
101+
assert.NotPanics(t, func() {
102+
model.Update(msg)
103+
})
104+
})
105+
106+
t.Run("stepSelectAccount with uninitialized list does not panic on key msg", func(t *testing.T) {
107+
model := NewFirstRunModel()
108+
model.currentStep = stepSelectAccount
109+
// accountList is zero-value (not initialized)
110+
111+
msg := tea.KeyMsg{Type: tea.KeyDown}
112+
113+
assert.NotPanics(t, func() {
114+
model.Update(msg)
115+
})
116+
})
117+
118+
t.Run("stepSelectAccount with uninitialized list does not panic on window resize", func(t *testing.T) {
119+
model := NewFirstRunModel()
120+
model.currentStep = stepSelectAccount
121+
122+
msg := tea.WindowSizeMsg{Width: 80, Height: 24}
123+
124+
assert.NotPanics(t, func() {
125+
model.Update(msg)
126+
})
127+
})
128+
129+
t.Run("stepSelectAccount with uninitialized list does not panic on spinner tick", func(t *testing.T) {
130+
model := NewFirstRunModel()
131+
model.currentStep = stepSelectAccount
132+
133+
msg := spinner.TickMsg{Time: time.Now()}
134+
135+
assert.NotPanics(t, func() {
136+
model.Update(msg)
137+
})
138+
})
139+
}
140+
141+
func TestFirstRunModel_InitializedListForwardsMessages(t *testing.T) {
142+
t.Run("stepSelectProject forwards messages when list is populated", func(t *testing.T) {
143+
model := NewFirstRunModel()
144+
model.currentStep = stepSelectProject
145+
146+
items := []list.Item{
147+
projectItem{id: "1", name: "Project A", desc: "Desc A"},
148+
projectItem{id: "2", name: "Project B", desc: "Desc B"},
149+
}
150+
model.projectList = list.New(items, itemDelegate{}, 60, 10)
151+
152+
msg := tea.KeyMsg{Type: tea.KeyDown}
153+
154+
assert.NotPanics(t, func() {
155+
newModel, _ := model.Update(msg)
156+
m := newModel.(FirstRunModel)
157+
// List should have processed the message
158+
assert.Equal(t, stepSelectProject, m.currentStep)
159+
assert.Equal(t, 2, len(m.projectList.Items()))
160+
})
161+
})
162+
163+
t.Run("stepSelectAccount forwards messages when list is populated", func(t *testing.T) {
164+
model := NewFirstRunModel()
165+
model.currentStep = stepSelectAccount
166+
167+
items := []list.Item{
168+
accountItem{id: "1", name: "Account A"},
169+
accountItem{id: "2", name: "Account B"},
170+
}
171+
model.accountList = list.New(items, itemDelegate{}, 50, 10)
172+
173+
msg := tea.KeyMsg{Type: tea.KeyDown}
174+
175+
assert.NotPanics(t, func() {
176+
newModel, _ := model.Update(msg)
177+
m := newModel.(FirstRunModel)
178+
assert.Equal(t, stepSelectAccount, m.currentStep)
179+
assert.Equal(t, 2, len(m.accountList.Items()))
180+
})
181+
})
182+
}
183+
184+
func TestFirstRunModel_StepTransitions(t *testing.T) {
185+
t.Run("tab switches from clientID to clientSecret", func(t *testing.T) {
186+
model := NewFirstRunModel()
187+
model.currentStep = stepClientID
188+
msg := tea.KeyMsg{Type: tea.KeyTab}
189+
190+
newModel, _ := model.Update(msg)
191+
m := newModel.(FirstRunModel)
192+
193+
assert.Equal(t, stepClientSecret, m.currentStep)
194+
})
195+
196+
t.Run("shift-tab switches from clientSecret to clientID", func(t *testing.T) {
197+
model := NewFirstRunModel()
198+
model.currentStep = stepClientSecret
199+
msg := tea.KeyMsg{Type: tea.KeyShiftTab}
200+
201+
newModel, _ := model.Update(msg)
202+
m := newModel.(FirstRunModel)
203+
204+
assert.Equal(t, stepClientID, m.currentStep)
205+
})
206+
207+
t.Run("enter with empty clientID shows error", func(t *testing.T) {
208+
model := NewFirstRunModel()
209+
model.currentStep = stepClientID
210+
msg := tea.KeyMsg{Type: tea.KeyEnter}
211+
212+
newModel, _ := model.Update(msg)
213+
m := newModel.(FirstRunModel)
214+
215+
assert.NotNil(t, m.err)
216+
assert.Equal(t, stepClientID, m.currentStep)
217+
})
218+
219+
t.Run("enter with clientID advances to clientSecret", func(t *testing.T) {
220+
model := NewFirstRunModel()
221+
model.currentStep = stepClientID
222+
model.clientID.SetValue("test-client-id")
223+
msg := tea.KeyMsg{Type: tea.KeyEnter}
224+
225+
newModel, _ := model.Update(msg)
226+
m := newModel.(FirstRunModel)
227+
228+
assert.Equal(t, stepClientSecret, m.currentStep)
229+
})
230+
231+
t.Run("enter with empty clientSecret shows error", func(t *testing.T) {
232+
model := NewFirstRunModel()
233+
model.currentStep = stepClientSecret
234+
msg := tea.KeyMsg{Type: tea.KeyEnter}
235+
236+
newModel, _ := model.Update(msg)
237+
m := newModel.(FirstRunModel)
238+
239+
assert.NotNil(t, m.err)
240+
assert.Equal(t, stepClientSecret, m.currentStep)
241+
})
242+
243+
t.Run("enter on complete step quits", func(t *testing.T) {
244+
model := NewFirstRunModel()
245+
model.currentStep = stepComplete
246+
msg := tea.KeyMsg{Type: tea.KeyEnter}
247+
248+
_, cmd := model.Update(msg)
249+
250+
assert.NotNil(t, cmd)
251+
cmdMsg := cmd()
252+
_, isQuit := cmdMsg.(tea.QuitMsg)
253+
assert.True(t, isQuit)
254+
})
255+
}
256+
257+
func TestFirstRunModel_View(t *testing.T) {
258+
t.Run("welcome view", func(t *testing.T) {
259+
model := NewFirstRunModel()
260+
model.currentStep = stepWelcome
261+
model.width = 80
262+
model.height = 24
263+
264+
view := model.View()
265+
assert.Contains(t, view, "Welcome to bc4!")
266+
assert.Contains(t, view, "Press Enter to continue")
267+
})
268+
269+
t.Run("clientID view", func(t *testing.T) {
270+
model := NewFirstRunModel()
271+
model.currentStep = stepClientID
272+
model.width = 80
273+
model.height = 24
274+
275+
view := model.View()
276+
assert.Contains(t, view, "OAuth Setup")
277+
assert.Contains(t, view, "Client ID")
278+
})
279+
280+
t.Run("clientSecret view", func(t *testing.T) {
281+
model := NewFirstRunModel()
282+
model.currentStep = stepClientSecret
283+
model.width = 80
284+
model.height = 24
285+
286+
view := model.View()
287+
assert.Contains(t, view, "Client Secret")
288+
})
289+
290+
t.Run("authenticate view", func(t *testing.T) {
291+
model := NewFirstRunModel()
292+
model.currentStep = stepAuthenticate
293+
model.width = 80
294+
model.height = 24
295+
296+
view := model.View()
297+
assert.Contains(t, view, "Authenticating")
298+
assert.Contains(t, view, "browser")
299+
})
300+
301+
t.Run("select account loading view", func(t *testing.T) {
302+
model := NewFirstRunModel()
303+
model.currentStep = stepSelectAccount
304+
model.width = 80
305+
model.height = 24
306+
307+
view := model.View()
308+
assert.Contains(t, view, "Loading accounts")
309+
})
310+
311+
t.Run("select project loading view", func(t *testing.T) {
312+
model := NewFirstRunModel()
313+
model.currentStep = stepSelectProject
314+
model.width = 80
315+
model.height = 24
316+
317+
view := model.View()
318+
assert.Contains(t, view, "Loading projects")
319+
})
320+
321+
t.Run("complete view", func(t *testing.T) {
322+
model := NewFirstRunModel()
323+
model.currentStep = stepComplete
324+
model.width = 80
325+
model.height = 24
326+
327+
view := model.View()
328+
assert.Contains(t, view, "Setup Complete")
329+
})
330+
}

0 commit comments

Comments
 (0)