Skip to content

Commit ba36e17

Browse files
committed
fix: Using synctest to drive the model
1 parent c4a9f8e commit ba36e17

6 files changed

Lines changed: 391 additions & 351 deletions

File tree

internal/cli/commands/catalog/tui/redesign/model.go

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,8 @@ func (m Model) ExitMessage() string {
105105
func (m Model) Init() tea.Cmd {
106106
cmds := []tea.Cmd{
107107
m.buttonBar.Init(),
108-
// Ask the terminal for its background color so the README renderer
109-
// picks dark vs light without a synchronous OSC 11 round-trip on
110-
// the hot path. The reply arrives as tea.BackgroundColorMsg.
108+
// Reply arrives as tea.BackgroundColorMsg; cached so the README
109+
// renderer doesn't have to issue an OSC 11 round-trip per click.
111110
tea.RequestBackgroundColor,
112111
}
113112

@@ -303,9 +302,8 @@ func newModelWithItems(l log.Logger, opts *options.TerragruntOptions, items []li
303302
terragruntOptions: opts,
304303
logger: l,
305304
componentCh: componentCh,
306-
// Default to dark: matches lipgloss.HasDarkBackground's fallback.
307-
// Bubbletea delivers a tea.BackgroundColorMsg at startup that
308-
// corrects this when the terminal actually reports light.
305+
// Matches lipgloss.HasDarkBackground's fallback. Corrected on the
306+
// first tea.BackgroundColorMsg.
309307
hasDarkBG: true,
310308
}
311309
}
Lines changed: 104 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
package redesign_test
22

33
import (
4-
"bytes"
5-
"os"
64
"path/filepath"
75
"strings"
86
"testing"
9-
"time"
7+
"testing/synctest"
108

119
tea "charm.land/bubbletea/v2"
12-
"github.com/charmbracelet/colorprofile"
1310

1411
"github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui/redesign"
1512
"github.com/gruntwork-io/terragrunt/pkg/options"
@@ -19,50 +16,6 @@ import (
1916
"github.com/stretchr/testify/require"
2017
)
2118

22-
// runModel starts a tea.Program with the given model, sends messages via
23-
// the interact callback, and returns the final model once the program exits.
24-
func runModel(t *testing.T, m redesign.Model, width, height int, interact func(p *tea.Program)) redesign.Model {
25-
t.Helper()
26-
27-
var out bytes.Buffer
28-
29-
pr, pw, err := os.Pipe()
30-
require.NoError(t, err)
31-
32-
defer pr.Close()
33-
defer pw.Close()
34-
35-
p := tea.NewProgram(m,
36-
tea.WithInput(pr),
37-
tea.WithOutput(&out),
38-
tea.WithWindowSize(width, height),
39-
tea.WithColorProfile(colorprofile.TrueColor),
40-
)
41-
42-
done := make(chan tea.Model, 1)
43-
44-
go func() {
45-
finalModel, err := p.Run()
46-
assert.NoError(t, err)
47-
48-
done <- finalModel
49-
}()
50-
51-
time.Sleep(50 * time.Millisecond)
52-
53-
interact(p)
54-
55-
select {
56-
case fm := <-done:
57-
return fm.(redesign.Model)
58-
case <-time.After(10 * time.Second):
59-
p.Kill()
60-
t.Fatal("program did not exit within timeout")
61-
62-
return redesign.Model{}
63-
}
64-
}
65-
6619
// makeComponents builds a deterministic list of ComponentEntry values for
6720
// testing. Each entry has a distinct Dir so Title() returns the directory
6821
// basename and sort order is predictable.
@@ -90,38 +43,37 @@ func makeComponents(t *testing.T) []*redesign.ComponentEntry {
9043
func TestModelStreamingInsertsSorted(t *testing.T) {
9144
t.Parallel()
9245

93-
opts, err := options.NewTerragruntOptionsForTest("")
94-
require.NoError(t, err)
46+
synctest.Test(t, func(t *testing.T) {
47+
opts, err := options.NewTerragruntOptionsForTest("")
48+
require.NoError(t, err)
9549

96-
l := logger.CreateLogger()
97-
components := makeComponents(t)
98-
require.GreaterOrEqual(t, len(components), 2, "need at least 2 components")
50+
l := logger.CreateLogger()
51+
components := makeComponents(t)
52+
require.GreaterOrEqual(t, len(components), 2, "need at least 2 components")
9953

100-
// Start with the last component alphabetically
101-
componentCh := make(chan *redesign.ComponentEntry, len(components))
102-
m := redesign.NewModelStreaming(l, opts, components[len(components)-1], componentCh)
54+
componentCh := make(chan *redesign.ComponentEntry, len(components))
55+
m := redesign.NewModelStreaming(l, opts, components[len(components)-1], componentCh)
56+
close(componentCh)
10357

104-
finalModel := runModel(t, m, 120, 40, func(p *tea.Program) {
105-
// Send the remaining components in reverse order
58+
msgs := make([]tea.Msg, 0, len(components))
10659
for i := len(components) - 2; i >= 0; i-- {
107-
p.Send(redesign.ComponentMsg(components[i]))
108-
time.Sleep(50 * time.Millisecond)
60+
msgs = append(msgs, redesign.ComponentMsg(components[i]))
10961
}
11062

111-
time.Sleep(100 * time.Millisecond)
63+
msgs = append(msgs, tea.KeyPressMsg{Code: 'q', Text: "q"})
11264

113-
p.Send(tea.KeyPressMsg{Code: 'q', Text: "q"})
114-
})
65+
finalModel := driveModel(t, m, 120, 40, msgs).(redesign.Model)
11566

116-
assert.Equal(t, redesign.ListState, finalModel.State)
117-
items := finalModel.List().Items()
118-
assert.Len(t, items, len(components), "all components should be in the list")
67+
assert.Equal(t, redesign.ListState, finalModel.State)
68+
items := finalModel.List().Items()
69+
assert.Len(t, items, len(components), "all components should be in the list")
11970

120-
for i := 1; i < len(items); i++ {
121-
prev := strings.ToLower(items[i-1].(*redesign.ComponentEntry).Title())
122-
curr := strings.ToLower(items[i].(*redesign.ComponentEntry).Title())
123-
assert.LessOrEqual(t, prev, curr, "components should be in alphabetical order: %q should come before %q", prev, curr)
124-
}
71+
for i := 1; i < len(items); i++ {
72+
prev := strings.ToLower(items[i-1].(*redesign.ComponentEntry).Title())
73+
curr := strings.ToLower(items[i].(*redesign.ComponentEntry).Title())
74+
assert.LessOrEqual(t, prev, curr, "components should be in alphabetical order: %q should come before %q", prev, curr)
75+
}
76+
})
12577
}
12678

12779
// makeMixedComponents returns a module entry followed by a template entry
@@ -150,131 +102,132 @@ func makeMixedComponents(t *testing.T) []*redesign.ComponentEntry {
150102
func TestModelTabsFilterByKind(t *testing.T) {
151103
t.Parallel()
152104

153-
opts, err := options.NewTerragruntOptionsForTest("")
154-
require.NoError(t, err)
105+
synctest.Test(t, func(t *testing.T) {
106+
opts, err := options.NewTerragruntOptionsForTest("")
107+
require.NoError(t, err)
155108

156-
l := logger.CreateLogger()
157-
components := makeMixedComponents(t)
109+
l := logger.CreateLogger()
110+
components := makeMixedComponents(t)
158111

159-
componentCh := make(chan *redesign.ComponentEntry, len(components))
160-
m := redesign.NewModelStreaming(l, opts, components[0], componentCh)
161-
162-
finalModel := runModel(t, m, 120, 40, func(p *tea.Program) {
163-
p.Send(redesign.ComponentMsg(components[1]))
164-
time.Sleep(100 * time.Millisecond)
112+
componentCh := make(chan *redesign.ComponentEntry, len(components))
113+
m := redesign.NewModelStreaming(l, opts, components[0], componentCh)
114+
close(componentCh)
165115

166116
// Cycle: All -> Templates (first tab after All in the current order).
167-
p.Send(tea.KeyPressMsg{Code: tea.KeyTab})
168-
time.Sleep(30 * time.Millisecond)
117+
msgs := []tea.Msg{
118+
redesign.ComponentMsg(components[1]),
119+
tea.KeyPressMsg{Code: tea.KeyTab},
120+
tea.KeyPressMsg{Code: 'q', Text: "q"},
121+
}
169122

170-
p.Send(tea.KeyPressMsg{Code: 'q', Text: "q"})
171-
})
123+
finalModel := driveModel(t, m, 120, 40, msgs).(redesign.Model)
172124

173-
assert.Equal(t, redesign.TabTemplates, finalModel.ActiveTab(), "tab key should cycle to Templates")
125+
assert.Equal(t, redesign.TabTemplates, finalModel.ActiveTab(), "tab key should cycle to Templates")
174126

175-
templatesItems := finalModel.List().Items()
176-
require.Len(t, templatesItems, 1, "Templates tab should contain only the one template")
177-
assert.Equal(t, redesign.ComponentKindTemplate, templatesItems[0].(*redesign.ComponentEntry).Kind())
127+
templatesItems := finalModel.List().Items()
128+
require.Len(t, templatesItems, 1, "Templates tab should contain only the one template")
129+
assert.Equal(t, redesign.ComponentKindTemplate, templatesItems[0].(*redesign.ComponentEntry).Kind())
130+
})
178131
}
179132

180133
// TestModelTabShiftTabCycles verifies that shift+tab cycles tabs in
181134
// reverse order.
182135
func TestModelTabShiftTabCycles(t *testing.T) {
183136
t.Parallel()
184137

185-
opts, err := options.NewTerragruntOptionsForTest("")
186-
require.NoError(t, err)
138+
synctest.Test(t, func(t *testing.T) {
139+
opts, err := options.NewTerragruntOptionsForTest("")
140+
require.NoError(t, err)
187141

188-
l := logger.CreateLogger()
189-
components := makeMixedComponents(t)
142+
l := logger.CreateLogger()
143+
components := makeMixedComponents(t)
190144

191-
componentCh := make(chan *redesign.ComponentEntry, len(components))
192-
m := redesign.NewModelStreaming(l, opts, components[0], componentCh)
193-
194-
finalModel := runModel(t, m, 120, 40, func(p *tea.Program) {
195-
p.Send(redesign.ComponentMsg(components[1]))
196-
time.Sleep(100 * time.Millisecond)
145+
componentCh := make(chan *redesign.ComponentEntry, len(components))
146+
m := redesign.NewModelStreaming(l, opts, components[0], componentCh)
147+
close(componentCh)
197148

198149
// Starts on All. Shift+Tab wraps to the last tab (Stacks).
199-
p.Send(tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift})
200-
time.Sleep(30 * time.Millisecond)
150+
msgs := []tea.Msg{
151+
redesign.ComponentMsg(components[1]),
152+
tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift},
153+
tea.KeyPressMsg{Code: 'q', Text: "q"},
154+
}
201155

202-
p.Send(tea.KeyPressMsg{Code: 'q', Text: "q"})
203-
})
156+
finalModel := driveModel(t, m, 120, 40, msgs).(redesign.Model)
204157

205-
assert.Equal(t, redesign.TabModules, finalModel.ActiveTab(), "shift+tab from All should wrap to the last tab")
158+
assert.Equal(t, redesign.TabModules, finalModel.ActiveTab(), "shift+tab from All should wrap to the last tab")
159+
})
206160
}
207161

208-
// TestModelCopyActionMaterializesUnit drives the Model through a real
209-
// scaffold-key press on a unit component and asserts that the copy action
210-
// runs end-to-end: the unit's files land in the working directory and a
211-
// terragrunt.values.hcl stub is generated from the referenced values.*.
212-
func TestModelCopyActionMaterializesUnit(t *testing.T) {
162+
// TestModelCopyActionTransitionsToScaffoldState asserts that pressing the
163+
// scaffold key on a copyable component transitions the Model to
164+
// ScaffoldState, which is what dispatches the copy action.
165+
//
166+
// The copy itself is exercised end-to-end in copy_test.go; here we only
167+
// verify the wire-up, because tea.Exec (used by the copy dispatch) only runs
168+
// the underlying ExecCommand inside a real bubbletea runtime.
169+
func TestModelCopyActionTransitionsToScaffoldState(t *testing.T) {
213170
t.Parallel()
214171

215-
repoDir := helpers.TmpDirWOSymlinks(t)
172+
synctest.Test(t, func(t *testing.T) {
173+
repoDir := helpers.TmpDirWOSymlinks(t)
216174

217-
unitBody := `locals { region = values.region }` + "\n"
218-
writeFile(t, filepath.Join(repoDir, "vpc", "terragrunt.hcl"), unitBody)
175+
unitBody := `locals { region = values.region }` + "\n"
176+
writeFile(t, filepath.Join(repoDir, "vpc", "terragrunt.hcl"), unitBody)
219177

220-
repo := newFakeRepo(t, repoDir)
178+
repo := newFakeRepo(t, repoDir)
221179

222-
components, err := redesign.NewComponentDiscovery().Discover(repo)
223-
require.NoError(t, err)
224-
require.Len(t, components, 1)
225-
require.Equal(t, redesign.ComponentKindUnit, components[0].Kind)
180+
components, err := redesign.NewComponentDiscovery().Discover(repo)
181+
require.NoError(t, err)
182+
require.Len(t, components, 1)
183+
require.Equal(t, redesign.ComponentKindUnit, components[0].Kind)
226184

227-
workingDir := t.TempDir()
185+
opts, err := options.NewTerragruntOptionsForTest("")
186+
require.NoError(t, err)
228187

229-
opts, err := options.NewTerragruntOptionsForTest("")
230-
require.NoError(t, err)
188+
opts.WorkingDir = t.TempDir()
231189

232-
opts.WorkingDir = workingDir
190+
entry := redesign.NewComponentEntry(components[0]).WithSource("github.com/gruntwork-io/fake-repo")
233191

234-
entry := redesign.NewComponentEntry(components[0]).WithSource("github.com/gruntwork-io/fake-repo")
192+
componentCh := make(chan *redesign.ComponentEntry)
193+
close(componentCh)
235194

236-
componentCh := make(chan *redesign.ComponentEntry)
237-
m := redesign.NewModelStreaming(logger.CreateLogger(), opts, entry, componentCh)
238-
239-
finalModel := runModel(t, m, 120, 40, func(p *tea.Program) {
240-
// 's' is the scaffold key. For units and stacks, this dispatches to
241-
// the copy action via primaryActionCmd.
242-
p.Send(tea.KeyPressMsg{Code: 's', Text: "s"})
243-
})
195+
m := redesign.NewModelStreaming(logger.CreateLogger(), opts, entry, componentCh)
244196

245-
assert.FileExists(t, filepath.Join(workingDir, "terragrunt.hcl"))
246-
assert.FileExists(t, filepath.Join(workingDir, "terragrunt.values.hcl"))
197+
msgs := []tea.Msg{tea.KeyPressMsg{Code: 's', Text: "s"}}
247198

248-
raw, err := os.ReadFile(filepath.Join(workingDir, "terragrunt.values.hcl"))
249-
require.NoError(t, err)
250-
assert.Contains(t, string(raw), "region")
199+
finalModel := driveModel(t, m, 120, 40, msgs).(redesign.Model)
251200

252-
assert.Contains(t, finalModel.ExitMessage(), "terragrunt.values.hcl generated",
253-
"the model should stash a post-exit callout after a successful copy")
201+
assert.Equal(t, redesign.ScaffoldState, finalModel.State,
202+
"pressing 's' on a unit should transition to ScaffoldState")
203+
})
254204
}
255205

256206
// TestModelStreamingDeduplicates verifies that sending the same component
257207
// twice does not result in a duplicate entry in the list.
258208
func TestModelStreamingDeduplicates(t *testing.T) {
259209
t.Parallel()
260210

261-
opts, err := options.NewTerragruntOptionsForTest("")
262-
require.NoError(t, err)
211+
synctest.Test(t, func(t *testing.T) {
212+
opts, err := options.NewTerragruntOptionsForTest("")
213+
require.NoError(t, err)
214+
215+
l := logger.CreateLogger()
216+
components := makeComponents(t)
217+
require.NotEmpty(t, components)
263218

264-
l := logger.CreateLogger()
265-
components := makeComponents(t)
266-
require.NotEmpty(t, components)
219+
componentCh := make(chan *redesign.ComponentEntry, len(components))
220+
m := redesign.NewModelStreaming(l, opts, components[0], componentCh)
221+
close(componentCh)
267222

268-
componentCh := make(chan *redesign.ComponentEntry, len(components))
269-
m := redesign.NewModelStreaming(l, opts, components[0], componentCh)
223+
msgs := []tea.Msg{
224+
redesign.ComponentMsg(components[0]),
225+
tea.KeyPressMsg{Code: 'q', Text: "q"},
226+
}
270227

271-
finalModel := runModel(t, m, 120, 40, func(p *tea.Program) {
272-
p.Send(redesign.ComponentMsg(components[0]))
273-
time.Sleep(100 * time.Millisecond)
228+
finalModel := driveModel(t, m, 120, 40, msgs).(redesign.Model)
274229

275-
p.Send(tea.KeyPressMsg{Code: 'q', Text: "q"})
230+
assert.Equal(t, redesign.ListState, finalModel.State)
231+
assert.Len(t, finalModel.List().Items(), 1, "duplicate component should not appear twice")
276232
})
277-
278-
assert.Equal(t, redesign.ListState, finalModel.State)
279-
assert.Len(t, finalModel.List().Items(), 1, "duplicate component should not appear twice")
280233
}

internal/cli/commands/catalog/tui/redesign/renderer.go

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,12 @@ import (
44
"charm.land/glamour/v2"
55
)
66

7-
// markdownRenderer returns a glamour renderer matching the current Model
8-
// width and dark/light setting. It rebuilds only when the width or the
9-
// background preference changes; otherwise the cached renderer is reused.
7+
// markdownRenderer returns a renderer matching the current width and
8+
// dark/light setting, reusing a cached one when both still match.
109
//
11-
// Building a glamour.TermRenderer compiles a goldmark pipeline and loads the
12-
// chroma theme registry. Doing that per click is the dominant source of
13-
// latency when opening a component's README; caching keeps subsequent opens
14-
// in the same terminal single-frame.
15-
//
16-
// The returned renderer is also stored back on m, so callers that receive a
17-
// Model by value must propagate the returned Model up to their caller.
10+
// The cache lives on the Model, which is passed by value, so callers must
11+
// propagate the returned Model upward; otherwise the cache write is lost on
12+
// the next copy.
1813
func (m Model) markdownRenderer() (Model, *glamour.TermRenderer, error) {
1914
if m.mdRenderer != nil && m.mdRendererWidth == m.width && m.mdRendererDark == m.hasDarkBG {
2015
return m, m.mdRenderer, nil

0 commit comments

Comments
 (0)