package app import ( "os" "path/filepath" "strings" "testing" "time" "github.com/gdamore/tcell/v2" "codeberg.org/turbo-editors/turbo-core/tools" "codeberg.org/turbo-editors/turbo-core/ui" ) // newToolsApp returns an editor whose working directory is a project holding // the given tools file. func newToolsApp(t *testing.T, toolsTOML string) (*App, string) { t.Helper() root := t.TempDir() t.Chdir(root) if toolsTOML != "" { path := tools.Path(testProfile(), root) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("creating the tools directory: %v", err) } if err := os.WriteFile(path, []byte(toolsTOML), 0o644); err != nil { t.Fatalf("writing the tools file: %v", err) } } a, _ := newTestApp(t) return a, root } // toolchainMenuOf returns the editor's toolchain menu with its items filled // in, which is what OnOpen does just before it drops down. func toolchainMenuOf(t *testing.T, a *App) *ui.Menu { t.Helper() want := a.toolsMenuName() for _, menu := range a.menu.Menus() { if ui.PlainLabel(menu.Label) != want { continue } if menu.OnOpen == nil { t.Fatalf("the %s menu has no OnOpen, so it can never be filled", want) } menu.OnOpen() return menu } t.Fatalf("there is no %s menu on the bar", want) return nil } func TestThereIsAlwaysAToolchainMenu(t *testing.T) { // Without one, the item that creates the file is unreachable. a, _ := newToolsApp(t, "") menu := toolchainMenuOf(t, a) want := []string{"Create tools file", "Open tools file"} if got := labels(menu.Items); !sameLabels(got, want) { t.Errorf("an empty project's toolchain menu holds %v, want %v", got, want) } } func TestTheToolchainMenuIsNamedByTheProfile(t *testing.T) { // It is "Go" in Turbo Go and "Rust" in Turbo Rust; this package chooses // neither, which is the whole point of the profile. a, _ := newToolsApp(t, "") if got := toolchainMenuOf(t, a).Label; got != testProfile().ToolsMenu { t.Errorf("the menu is labelled %q, want %q", got, testProfile().ToolsMenu) } } func TestTheToolchainMenuIsBuiltFromTheFile(t *testing.T) { a, _ := newToolsApp(t, ` [[tool]] name = "~T~est" command = "go test ./..." [[tool]] name = "~B~uild" command = "go build ./..." `) got := labels(toolchainMenuOf(t, a).Items) want := []string{"Test", "Build", "---", "Create tools file", "Open tools file"} if len(got) != len(want) { t.Fatalf("the menu holds %v, want %v", got, want) } for i := range want { if got[i] != want[i] { t.Errorf("item %d = %q, want %q", i, got[i], want[i]) } } } func TestAnUnreadableToolsFileIsSaidSoInTheMenu(t *testing.T) { a, _ := newToolsApp(t, "[[tool]\nname = ") items := toolchainMenuOf(t, a).Items if got := labels(items); len(got) == 0 || !strings.Contains(got[0], "Cannot read") { t.Errorf("the menu holds %v, want a line saying the file cannot be read", got) } if items[0].Enabled == nil || items[0].Enabled() { t.Error("the error line can be chosen") } if findItem(items, "Create tools file") == nil { t.Error("the create item is missing, so there is no way forward") } } func TestCreateToolsWritesAndOpensTheFile(t *testing.T) { a, root := newToolsApp(t, "") a.CreateTools() if !tools.Exists(testProfile(), root) { t.Fatal("no tools file was written") } if a.Desktop().Count() != 1 { t.Fatalf("Count() = %d, want the file open", a.Desktop().Count()) } if got := a.Desktop().Active().Title(); got != tools.FileName { t.Errorf("the window shows %q, want %q", got, tools.FileName) } } func TestTheCreatedFileFillsTheToolchainMenu(t *testing.T) { // The starter file has to be usable, not merely parseable. Which commands // are in it belongs to the editor's own template and its own test. a, _ := newToolsApp(t, "") a.CreateTools() got := labels(toolchainMenuOf(t, a).Items) if len(got) < 3 || got[len(got)-2] != "Create tools file" || got[len(got)-1] != "Open tools file" { t.Fatalf("the menu holds %v, want the template's commands then the create and open items", got) } if !containsString(got, "Build") { t.Errorf("the template's own command is not in the menu: %v", got) } } func TestCreateToolsTwiceOpensRatherThanOverwrites(t *testing.T) { a, root := newToolsApp(t, "[[tool]]\nname = \"Mine\"\ncommand = \"make\"\n") a.CreateTools() if contents := readTestFile(t, tools.Path(testProfile(), root)); !strings.Contains(contents, "make") { t.Errorf("the existing file was overwritten:\n%s", contents) } if a.Desktop().Count() != 1 { t.Errorf("Count() = %d, want the existing file opened", a.Desktop().Count()) } } func TestRunningAToolOpensATerminalWindowNamedAfterTheCommand(t *testing.T) { skipWithoutPTY(t) a, _ := newToolsApp(t, "") a.RunTool(tools.Tool{Name: "Hello", Command: "echo hello", Output: tools.OutputTerminal}) t.Cleanup(a.closeTerminals) if a.Desktop().Count() != 1 { t.Fatalf("Count() = %d, want the command's window", a.Desktop().Count()) } if got := a.Desktop().Active().Title(); got != "echo hello" { t.Errorf("the window is called %q, want the command", got) } if !a.isTerminalWindow(a.Desktop().Active()) { t.Error("the window does not hold a terminal") } } func TestAToolRunsTheCommandForReal(t *testing.T) { skipWithoutPTY(t) a, root := newToolsApp(t, "") // The quotes are what make this a test of the shell rather than of the // echo: nothing types the command into this terminal. a.RunTool(tools.Tool{Name: "Ran", Command: "echo turbo''-tools-ran > ran.txt", Output: tools.OutputTerminal}) t.Cleanup(a.closeTerminals) waitForFileContents(t, filepath.Join(root, "ran.txt"), "turbo-tools-ran") } func TestAToolReloadsAFileItRewrote(t *testing.T) { // This is the whole reason the reload exists: Format is the first item in // the menu and rewrites the file you are looking at. skipWithoutPTY(t) a, root := newToolsApp(t, "") path := filepath.Join(root, "main.go") writeTestFile(t, path, "before\n") a.Open(path) a.RunTool(tools.Tool{Name: "Rewrite", Command: "echo after > main.go", Output: tools.OutputTerminal}) t.Cleanup(a.closeTerminals) waitForLoopTurn(t, a, "the buffer to be reloaded", func() bool { return strings.Contains(activeEditorText(t, a, path), "after") }) } func TestAToolLeavesAModifiedBufferAlone(t *testing.T) { // A formatter and an unsaved edit genuinely conflict, and the editor is not // the one that should decide which wins. skipWithoutPTY(t) a, root := newToolsApp(t, "") path := filepath.Join(root, "main.go") writeTestFile(t, path, "before\n") a.Open(path) typeText(a, "// mine") edited := activeEditorText(t, a, path) a.RunTool(tools.Tool{Name: "Rewrite", Command: "echo after > main.go", Output: tools.OutputTerminal}) t.Cleanup(a.closeTerminals) // Wait for the command to have really rewritten the file, then give the // event loop several turns to reload it — which it must not do. waitForFileContents(t, path, "after") for range 20 { a.reloadAfterTools() } if got := activeEditorText(t, a, path); got != edited { t.Errorf("the modified buffer was reloaded: %q, want the unsaved edit %q", got, edited) } } // waitForLoopTurn takes turns of the event loop until a condition holds. // // Run is not running in a test, so the turn has to be taken by hand — and it // takes the *whole* turn, `a.tick()`, rather than the one step under test. // Calling the step directly would make the test pass with that step removed // from the loop, which is exactly the vacuous test this replaced. func waitForLoopTurn(t *testing.T, a *App, description string, condition func() bool) { t.Helper() deadline := time.After(20 * time.Second) for { a.tick() if condition() { return } select { case <-deadline: t.Fatalf("gave up waiting for %s", description) case <-time.After(10 * time.Millisecond): } } } // waitForFileContents waits until a file on disk holds a piece of text. func waitForFileContents(t *testing.T, path, want string) { t.Helper() deadline := time.After(20 * time.Second) for { data, err := os.ReadFile(path) if err == nil && strings.Contains(string(data), want) { return } select { case <-deadline: t.Fatalf("%s never held %q (last read: %q, %v)", path, want, data, err) case <-time.After(10 * time.Millisecond): } } } // activeEditorText returns the text of the window editing a path. func activeEditorText(t *testing.T, a *App, path string) string { t.Helper() for _, window := range a.Desktop().Windows() { view, ok := editorViewOf(window) if ok && view.Buffer().Path() == path { return view.Buffer().Text() } } t.Fatalf("no window is editing %s", path) return "" } // containsString reports whether a slice holds a value. func containsString(list []string, want string) bool { for _, item := range list { if item == want { return true } } return false } // runAndDrain runs a tool and turns the event loop until its command has // finished, which is what fills the dialog in. func runAndDrain(t *testing.T, a *App, tool tools.Tool) { t.Helper() a.RunTool(tool) waitForLoopTurn(t, a, "the command to finish", func() bool { return a.running != nil && a.runningFinished() }) } // runningFinished reports whether the showing command has ended. func (a *App) runningFinished() bool { finished, _ := a.running.run.Done() return finished } // outputLines returns what the showing dialog holds. func outputLines(a *App) []string { return a.running.list.Items() } func TestAPopupToolShowsItsOutputInADialog(t *testing.T) { a, _ := newToolsApp(t, "") runAndDrain(t, a, tools.Tool{Name: "Echo", Command: "echo one; echo two"}) if a.Modals() != 1 { t.Fatalf("Modals() = %d, want the output dialog", a.Modals()) } got := outputLines(a) if len(got) != 2 || got[0] != "one" || got[1] != "two" { t.Errorf("the dialog holds %v, want the two lines", got) } } func TestThePopupOpensBeforeTheCommandFinishes(t *testing.T) { // A dialog appearing a few seconds later would swallow whatever was being // typed at that moment. a, _ := newToolsApp(t, "") a.RunTool(tools.Tool{Name: "Slow", Command: "sleep 5; echo done"}) t.Cleanup(func() { a.finishRun() }) if a.Modals() != 1 { t.Errorf("Modals() = %d; the dialog did not open straight away", a.Modals()) } if a.running == nil || a.runningFinished() { t.Error("the command was already finished, so this proves nothing") } } func TestTheTitleCarriesTheExitCode(t *testing.T) { // A silent success would otherwise give a dialog with nothing in it and no // way to tell that from one that had not started. for command, want := range map[string]string{ "true": "true — ok", "exit 3": "exit 3 — exit 3", } { t.Run(command, func(t *testing.T) { a, _ := newToolsApp(t, "") runAndDrain(t, a, tools.Tool{Name: "X", Command: command}) a.refreshRunningTool() if got := a.running.dialog.Title(); got != want { t.Errorf("the dialog is called %q, want %q", got, want) } }) } } func TestASilentCommandSaysSoRatherThanShowingNothing(t *testing.T) { // `go build ./...` succeeding prints nothing, and a blank dialog reads as // "nothing happened" rather than as "it worked". a, _ := newToolsApp(t, "") runAndDrain(t, a, tools.Tool{Name: "Build", Command: "true"}) a.refreshRunningTool() got := outputLines(a) if len(got) != 1 || got[0] != noOutputLine { t.Errorf("the dialog holds %v, want %q", got, noOutputLine) } if got := a.running.dialog.Title(); got != "true — ok" { t.Errorf("the dialog is called %q", got) } } func TestARunningCommandWithNoOutputYetIsBlank(t *testing.T) { // "(no output)" is a verdict, and a command still running has not reached // one. a, _ := newToolsApp(t, "") a.RunTool(tools.Tool{Name: "Slow", Command: "sleep 5"}) t.Cleanup(func() { a.finishRun() }) a.refreshRunningTool() if got := outputLines(a); len(got) != 0 { t.Errorf("a running command's dialog holds %v, want nothing yet", got) } } func TestClosingThePopupStopsACommandStillRunning(t *testing.T) { // There is no other way to interrupt one whose output is not in a // terminal, and leaving it running with nowhere to show itself is worse. a, _ := newToolsApp(t, "") a.RunTool(tools.Tool{Name: "Slow", Command: "sleep 60"}) run := a.running.run press(a, tcell.KeyEscape, 0, tcell.ModNone) a.settleModals() if a.running != nil { t.Error("closing the dialog left the command showing") } waitForRun(t, run) if _, code := run.Done(); code == 0 { t.Error("the command was not stopped") } } func TestAnEditorToolOpensAWindowWithTheOutput(t *testing.T) { a, _ := newToolsApp(t, "") runAndDrain(t, a, tools.Tool{Name: "List", Command: "echo one; echo two", Output: tools.OutputEditor}) press(a, tcell.KeyEscape, 0, tcell.ModNone) a.settleModals() if a.Desktop().Count() != 1 { t.Fatalf("Count() = %d, want the output window", a.Desktop().Count()) } if got := activeBuffer(t, a).Text(); got != "one\ntwo" { t.Errorf("the window holds %q", got) } if got := a.Desktop().Active().Title(); got != "echo one; echo two" { t.Errorf("the window is called %q, want the command", got) } } func TestAPopupToolOpensNoWindow(t *testing.T) { a, _ := newToolsApp(t, "") runAndDrain(t, a, tools.Tool{Name: "Echo", Command: "echo one"}) press(a, tcell.KeyEscape, 0, tcell.ModNone) a.settleModals() if a.Desktop().Count() != 0 { t.Errorf("Count() = %d; a popup tool left a window behind", a.Desktop().Count()) } } func TestATerminalToolOpensAWindowNotADialog(t *testing.T) { skipWithoutPTY(t) a, _ := newToolsApp(t, "") a.RunTool(tools.Tool{Name: "Shell", Command: "echo hello", Output: tools.OutputTerminal}) t.Cleanup(a.closeTerminals) if a.Modals() != 0 { t.Errorf("Modals() = %d; a terminal tool opened a dialog", a.Modals()) } if a.Desktop().Count() != 1 { t.Errorf("Count() = %d, want the terminal window", a.Desktop().Count()) } } func TestAToolWithNoOutputNamedGoesToAPopup(t *testing.T) { a, _ := newToolsApp(t, "") a.RunTool(tools.Tool{Name: "Echo", Command: "echo x"}) t.Cleanup(func() { a.finishRun() }) if a.Modals() != 1 { t.Errorf("Modals() = %d, want the default to be a popup", a.Modals()) } } // waitForRun waits for a command to end. func waitForRun(t *testing.T, run *tools.Run) { t.Helper() deadline := time.After(20 * time.Second) for { if finished, _ := run.Done(); finished { return } select { case <-deadline: t.Fatal("the command never ended") case <-time.After(5 * time.Millisecond): } } } func TestAPopupToolAlsoReloadsAFileItRewrote(t *testing.T) { // The popup is now the default, so this — not the terminal path — is what // Format actually goes through. a, root := newToolsApp(t, "") path := filepath.Join(root, "main.go") writeTestFile(t, path, "before\n") a.Open(path) runAndDrain(t, a, tools.Tool{Name: "Rewrite", Command: "echo after > main.go"}) waitForLoopTurn(t, a, "the buffer to be reloaded", func() bool { return strings.Contains(activeEditorText(t, a, path), "after") }) }