turbo-editors/turbo-corepublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

toolchain_test.go · 509 lines · 14.7 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 5h ago1package app
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/gdamore/tcell/v2"
11
12 "codeberg.org/turbo-editors/turbo-core/tools"
13 "codeberg.org/turbo-editors/turbo-core/ui"
14)
15
16// newToolsApp returns an editor whose working directory is a project holding
17// the given tools file.
18func newToolsApp(t *testing.T, toolsTOML string) (*App, string) {
19 t.Helper()
20
21 root := t.TempDir()
22 t.Chdir(root)
23
24 if toolsTOML != "" {
25 path := tools.Path(testProfile(), root)
26 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
27 t.Fatalf("creating the tools directory: %v", err)
28 }
29 if err := os.WriteFile(path, []byte(toolsTOML), 0o644); err != nil {
30 t.Fatalf("writing the tools file: %v", err)
31 }
32 }
33
34 a, _ := newTestApp(t)
35 return a, root
36}
37
38// toolchainMenuOf returns the editor's toolchain menu with its items filled
39// in, which is what OnOpen does just before it drops down.
40func toolchainMenuOf(t *testing.T, a *App) *ui.Menu {
41 t.Helper()
42
43 want := a.toolsMenuName()
44 for _, menu := range a.menu.Menus() {
45 if ui.PlainLabel(menu.Label) != want {
46 continue
47 }
48 if menu.OnOpen == nil {
49 t.Fatalf("the %s menu has no OnOpen, so it can never be filled", want)
50 }
51 menu.OnOpen()
52 return menu
53 }
54 t.Fatalf("there is no %s menu on the bar", want)
55 return nil
56}
57
58func TestThereIsAlwaysAToolchainMenu(t *testing.T) {
59 // Without one, the item that creates the file is unreachable.
60 a, _ := newToolsApp(t, "")
61
62 menu := toolchainMenuOf(t, a)
63
64 want := []string{"Create tools file", "Open tools file"}
65 if got := labels(menu.Items); !sameLabels(got, want) {
66 t.Errorf("an empty project's toolchain menu holds %v, want %v", got, want)
67 }
68}
69
70func TestTheToolchainMenuIsNamedByTheProfile(t *testing.T) {
71 // It is "Go" in Turbo Go and "Rust" in Turbo Rust; this package chooses
72 // neither, which is the whole point of the profile.
73 a, _ := newToolsApp(t, "")
74
75 if got := toolchainMenuOf(t, a).Label; got != testProfile().ToolsMenu {
76 t.Errorf("the menu is labelled %q, want %q", got, testProfile().ToolsMenu)
77 }
78}
79
80func TestTheToolchainMenuIsBuiltFromTheFile(t *testing.T) {
81 a, _ := newToolsApp(t, `
82[[tool]]
83name = "~T~est"
84command = "go test ./..."
85
86[[tool]]
87name = "~B~uild"
88command = "go build ./..."
89`)
90
91 got := labels(toolchainMenuOf(t, a).Items)
92
93 want := []string{"Test", "Build", "---", "Create tools file", "Open tools file"}
94 if len(got) != len(want) {
95 t.Fatalf("the menu holds %v, want %v", got, want)
96 }
97 for i := range want {
98 if got[i] != want[i] {
99 t.Errorf("item %d = %q, want %q", i, got[i], want[i])
100 }
101 }
102}
103
104func TestAnUnreadableToolsFileIsSaidSoInTheMenu(t *testing.T) {
105 a, _ := newToolsApp(t, "[[tool]\nname = ")
106
107 items := toolchainMenuOf(t, a).Items
108
109 if got := labels(items); len(got) == 0 || !strings.Contains(got[0], "Cannot read") {
110 t.Errorf("the menu holds %v, want a line saying the file cannot be read", got)
111 }
112 if items[0].Enabled == nil || items[0].Enabled() {
113 t.Error("the error line can be chosen")
114 }
115 if findItem(items, "Create tools file") == nil {
116 t.Error("the create item is missing, so there is no way forward")
117 }
118}
119
120func TestCreateToolsWritesAndOpensTheFile(t *testing.T) {
121 a, root := newToolsApp(t, "")
122
123 a.CreateTools()
124
125 if !tools.Exists(testProfile(), root) {
126 t.Fatal("no tools file was written")
127 }
128 if a.Desktop().Count() != 1 {
129 t.Fatalf("Count() = %d, want the file open", a.Desktop().Count())
130 }
131 if got := a.Desktop().Active().Title(); got != tools.FileName {
132 t.Errorf("the window shows %q, want %q", got, tools.FileName)
133 }
134}
135
136func TestTheCreatedFileFillsTheToolchainMenu(t *testing.T) {
137 // The starter file has to be usable, not merely parseable. Which commands
138 // are in it belongs to the editor's own template and its own test.
139 a, _ := newToolsApp(t, "")
140 a.CreateTools()
141
142 got := labels(toolchainMenuOf(t, a).Items)
143
144 if len(got) < 3 || got[len(got)-2] != "Create tools file" || got[len(got)-1] != "Open tools file" {
145 t.Fatalf("the menu holds %v, want the template's commands then the create and open items", got)
146 }
147 if !containsString(got, "Build") {
148 t.Errorf("the template's own command is not in the menu: %v", got)
149 }
150}
151
152func TestCreateToolsTwiceOpensRatherThanOverwrites(t *testing.T) {
153 a, root := newToolsApp(t, "[[tool]]\nname = \"Mine\"\ncommand = \"make\"\n")
154
155 a.CreateTools()
156
157 if contents := readTestFile(t, tools.Path(testProfile(), root)); !strings.Contains(contents, "make") {
158 t.Errorf("the existing file was overwritten:\n%s", contents)
159 }
160 if a.Desktop().Count() != 1 {
161 t.Errorf("Count() = %d, want the existing file opened", a.Desktop().Count())
162 }
163}
164
165func TestRunningAToolOpensATerminalWindowNamedAfterTheCommand(t *testing.T) {
166 skipWithoutPTY(t)
167 a, _ := newToolsApp(t, "")
168
169 a.RunTool(tools.Tool{Name: "Hello", Command: "echo hello", Output: tools.OutputTerminal})
170 t.Cleanup(a.closeTerminals)
171
172 if a.Desktop().Count() != 1 {
173 t.Fatalf("Count() = %d, want the command's window", a.Desktop().Count())
174 }
175 if got := a.Desktop().Active().Title(); got != "echo hello" {
176 t.Errorf("the window is called %q, want the command", got)
177 }
178 if !a.isTerminalWindow(a.Desktop().Active()) {
179 t.Error("the window does not hold a terminal")
180 }
181}
182
183func TestAToolRunsTheCommandForReal(t *testing.T) {
184 skipWithoutPTY(t)
185 a, root := newToolsApp(t, "")
186
187 // The quotes are what make this a test of the shell rather than of the
188 // echo: nothing types the command into this terminal.
189 a.RunTool(tools.Tool{Name: "Ran", Command: "echo turbo''-tools-ran > ran.txt", Output: tools.OutputTerminal})
190 t.Cleanup(a.closeTerminals)
191
192 waitForFileContents(t, filepath.Join(root, "ran.txt"), "turbo-tools-ran")
193}
194
195func TestAToolReloadsAFileItRewrote(t *testing.T) {
196 // This is the whole reason the reload exists: Format is the first item in
197 // the menu and rewrites the file you are looking at.
198 skipWithoutPTY(t)
199 a, root := newToolsApp(t, "")
200 path := filepath.Join(root, "main.go")
201 writeTestFile(t, path, "before\n")
202 a.Open(path)
203
204 a.RunTool(tools.Tool{Name: "Rewrite", Command: "echo after > main.go", Output: tools.OutputTerminal})
205 t.Cleanup(a.closeTerminals)
206
207 waitForLoopTurn(t, a, "the buffer to be reloaded", func() bool {
208 return strings.Contains(activeEditorText(t, a, path), "after")
209 })
210}
211
212func TestAToolLeavesAModifiedBufferAlone(t *testing.T) {
213 // A formatter and an unsaved edit genuinely conflict, and the editor is not
214 // the one that should decide which wins.
215 skipWithoutPTY(t)
216 a, root := newToolsApp(t, "")
217 path := filepath.Join(root, "main.go")
218 writeTestFile(t, path, "before\n")
219 a.Open(path)
220 typeText(a, "// mine")
221 edited := activeEditorText(t, a, path)
222
223 a.RunTool(tools.Tool{Name: "Rewrite", Command: "echo after > main.go", Output: tools.OutputTerminal})
224 t.Cleanup(a.closeTerminals)
225
226 // Wait for the command to have really rewritten the file, then give the
227 // event loop several turns to reload it — which it must not do.
228 waitForFileContents(t, path, "after")
229 for range 20 {
230 a.reloadAfterTools()
231 }
232
233 if got := activeEditorText(t, a, path); got != edited {
234 t.Errorf("the modified buffer was reloaded: %q, want the unsaved edit %q", got, edited)
235 }
236}
237
238// waitForLoopTurn takes turns of the event loop until a condition holds.
239//
240// Run is not running in a test, so the turn has to be taken by hand — and it
241// takes the *whole* turn, `a.tick()`, rather than the one step under test.
242// Calling the step directly would make the test pass with that step removed
243// from the loop, which is exactly the vacuous test this replaced.
244func waitForLoopTurn(t *testing.T, a *App, description string, condition func() bool) {
245 t.Helper()
246
247 deadline := time.After(20 * time.Second)
248 for {
249 a.tick()
250 if condition() {
251 return
252 }
253 select {
254 case <-deadline:
255 t.Fatalf("gave up waiting for %s", description)
256 case <-time.After(10 * time.Millisecond):
257 }
258 }
259}
260
261// waitForFileContents waits until a file on disk holds a piece of text.
262func waitForFileContents(t *testing.T, path, want string) {
263 t.Helper()
264
265 deadline := time.After(20 * time.Second)
266 for {
267 data, err := os.ReadFile(path)
268 if err == nil && strings.Contains(string(data), want) {
269 return
270 }
271 select {
272 case <-deadline:
273 t.Fatalf("%s never held %q (last read: %q, %v)", path, want, data, err)
274 case <-time.After(10 * time.Millisecond):
275 }
276 }
277}
278
279// activeEditorText returns the text of the window editing a path.
280func activeEditorText(t *testing.T, a *App, path string) string {
281 t.Helper()
282
283 for _, window := range a.Desktop().Windows() {
284 view, ok := editorViewOf(window)
285 if ok && view.Buffer().Path() == path {
286 return view.Buffer().Text()
287 }
288 }
289 t.Fatalf("no window is editing %s", path)
290 return ""
291}
292
293// containsString reports whether a slice holds a value.
294func containsString(list []string, want string) bool {
295 for _, item := range list {
296 if item == want {
297 return true
298 }
299 }
300 return false
301}
302
303// runAndDrain runs a tool and turns the event loop until its command has
304// finished, which is what fills the dialog in.
305func runAndDrain(t *testing.T, a *App, tool tools.Tool) {
306 t.Helper()
307
308 a.RunTool(tool)
309 waitForLoopTurn(t, a, "the command to finish", func() bool {
310 return a.running != nil && a.runningFinished()
311 })
312}
313
314// runningFinished reports whether the showing command has ended.
315func (a *App) runningFinished() bool {
316 finished, _ := a.running.run.Done()
317 return finished
318}
319
320// outputLines returns what the showing dialog holds.
321func outputLines(a *App) []string { return a.running.list.Items() }
322
323func TestAPopupToolShowsItsOutputInADialog(t *testing.T) {
324 a, _ := newToolsApp(t, "")
325
326 runAndDrain(t, a, tools.Tool{Name: "Echo", Command: "echo one; echo two"})
327
328 if a.Modals() != 1 {
329 t.Fatalf("Modals() = %d, want the output dialog", a.Modals())
330 }
331 got := outputLines(a)
332 if len(got) != 2 || got[0] != "one" || got[1] != "two" {
333 t.Errorf("the dialog holds %v, want the two lines", got)
334 }
335}
336
337func TestThePopupOpensBeforeTheCommandFinishes(t *testing.T) {
338 // A dialog appearing a few seconds later would swallow whatever was being
339 // typed at that moment.
340 a, _ := newToolsApp(t, "")
341
342 a.RunTool(tools.Tool{Name: "Slow", Command: "sleep 5; echo done"})
343 t.Cleanup(func() { a.finishRun() })
344
345 if a.Modals() != 1 {
346 t.Errorf("Modals() = %d; the dialog did not open straight away", a.Modals())
347 }
348 if a.running == nil || a.runningFinished() {
349 t.Error("the command was already finished, so this proves nothing")
350 }
351}
352
353func TestTheTitleCarriesTheExitCode(t *testing.T) {
354 // A silent success would otherwise give a dialog with nothing in it and no
355 // way to tell that from one that had not started.
356 for command, want := range map[string]string{
357 "true": "true — ok",
358 "exit 3": "exit 3 — exit 3",
359 } {
360 t.Run(command, func(t *testing.T) {
361 a, _ := newToolsApp(t, "")
362
363 runAndDrain(t, a, tools.Tool{Name: "X", Command: command})
364 a.refreshRunningTool()
365
366 if got := a.running.dialog.Title(); got != want {
367 t.Errorf("the dialog is called %q, want %q", got, want)
368 }
369 })
370 }
371}
372
373func TestASilentCommandSaysSoRatherThanShowingNothing(t *testing.T) {
374 // `go build ./...` succeeding prints nothing, and a blank dialog reads as
375 // "nothing happened" rather than as "it worked".
376 a, _ := newToolsApp(t, "")
377
378 runAndDrain(t, a, tools.Tool{Name: "Build", Command: "true"})
379 a.refreshRunningTool()
380
381 got := outputLines(a)
382 if len(got) != 1 || got[0] != noOutputLine {
383 t.Errorf("the dialog holds %v, want %q", got, noOutputLine)
384 }
385 if got := a.running.dialog.Title(); got != "true — ok" {
386 t.Errorf("the dialog is called %q", got)
387 }
388}
389
390func TestARunningCommandWithNoOutputYetIsBlank(t *testing.T) {
391 // "(no output)" is a verdict, and a command still running has not reached
392 // one.
393 a, _ := newToolsApp(t, "")
394 a.RunTool(tools.Tool{Name: "Slow", Command: "sleep 5"})
395 t.Cleanup(func() { a.finishRun() })
396
397 a.refreshRunningTool()
398
399 if got := outputLines(a); len(got) != 0 {
400 t.Errorf("a running command's dialog holds %v, want nothing yet", got)
401 }
402}
403
404func TestClosingThePopupStopsACommandStillRunning(t *testing.T) {
405 // There is no other way to interrupt one whose output is not in a
406 // terminal, and leaving it running with nowhere to show itself is worse.
407 a, _ := newToolsApp(t, "")
408 a.RunTool(tools.Tool{Name: "Slow", Command: "sleep 60"})
409 run := a.running.run
410
411 press(a, tcell.KeyEscape, 0, tcell.ModNone)
412 a.settleModals()
413
414 if a.running != nil {
415 t.Error("closing the dialog left the command showing")
416 }
417 waitForRun(t, run)
418 if _, code := run.Done(); code == 0 {
419 t.Error("the command was not stopped")
420 }
421}
422
423func TestAnEditorToolOpensAWindowWithTheOutput(t *testing.T) {
424 a, _ := newToolsApp(t, "")
425
426 runAndDrain(t, a, tools.Tool{Name: "List", Command: "echo one; echo two", Output: tools.OutputEditor})
427 press(a, tcell.KeyEscape, 0, tcell.ModNone)
428 a.settleModals()
429
430 if a.Desktop().Count() != 1 {
431 t.Fatalf("Count() = %d, want the output window", a.Desktop().Count())
432 }
433 if got := activeBuffer(t, a).Text(); got != "one\ntwo" {
434 t.Errorf("the window holds %q", got)
435 }
436 if got := a.Desktop().Active().Title(); got != "echo one; echo two" {
437 t.Errorf("the window is called %q, want the command", got)
438 }
439}
440
441func TestAPopupToolOpensNoWindow(t *testing.T) {
442 a, _ := newToolsApp(t, "")
443
444 runAndDrain(t, a, tools.Tool{Name: "Echo", Command: "echo one"})
445 press(a, tcell.KeyEscape, 0, tcell.ModNone)
446 a.settleModals()
447
448 if a.Desktop().Count() != 0 {
449 t.Errorf("Count() = %d; a popup tool left a window behind", a.Desktop().Count())
450 }
451}
452
453func TestATerminalToolOpensAWindowNotADialog(t *testing.T) {
454 skipWithoutPTY(t)
455 a, _ := newToolsApp(t, "")
456
457 a.RunTool(tools.Tool{Name: "Shell", Command: "echo hello", Output: tools.OutputTerminal})
458 t.Cleanup(a.closeTerminals)
459
460 if a.Modals() != 0 {
461 t.Errorf("Modals() = %d; a terminal tool opened a dialog", a.Modals())
462 }
463 if a.Desktop().Count() != 1 {
464 t.Errorf("Count() = %d, want the terminal window", a.Desktop().Count())
465 }
466}
467
468func TestAToolWithNoOutputNamedGoesToAPopup(t *testing.T) {
469 a, _ := newToolsApp(t, "")
470
471 a.RunTool(tools.Tool{Name: "Echo", Command: "echo x"})
472 t.Cleanup(func() { a.finishRun() })
473
474 if a.Modals() != 1 {
475 t.Errorf("Modals() = %d, want the default to be a popup", a.Modals())
476 }
477}
478
479// waitForRun waits for a command to end.
480func waitForRun(t *testing.T, run *tools.Run) {
481 t.Helper()
482
483 deadline := time.After(20 * time.Second)
484 for {
485 if finished, _ := run.Done(); finished {
486 return
487 }
488 select {
489 case <-deadline:
490 t.Fatal("the command never ended")
491 case <-time.After(5 * time.Millisecond):
492 }
493 }
494}
495
496func TestAPopupToolAlsoReloadsAFileItRewrote(t *testing.T) {
497 // The popup is now the default, so this — not the terminal path — is what
498 // Format actually goes through.
499 a, root := newToolsApp(t, "")
500 path := filepath.Join(root, "main.go")
501 writeTestFile(t, path, "before\n")
502 a.Open(path)
503
504 runAndDrain(t, a, tools.Tool{Name: "Rewrite", Command: "echo after > main.go"})
505
506 waitForLoopTurn(t, a, "the buffer to be reloaded", func() bool {
507 return strings.Contains(activeEditorText(t, a, path), "after")
508 })
509}