| 🛟 Updated. 28d5985 k33g 5h ago | 1 | package app |
| 2 | |
| 3 | import ( |
| 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. |
| 18 | func 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. |
| 40 | func 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 | |
| 58 | func 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 | |
| 70 | func 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 | |
| 80 | func TestTheToolchainMenuIsBuiltFromTheFile(t *testing.T) { |
| 81 | a, _ := newToolsApp(t, ` |
| 82 | [[tool]] |
| 83 | name = "~T~est" |
| 84 | command = "go test ./..." |
| 85 | |
| 86 | [[tool]] |
| 87 | name = "~B~uild" |
| 88 | command = "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 | |
| 104 | func 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 | |
| 120 | func 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 | |
| 136 | func 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 | |
| 152 | func 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 | |
| 165 | func 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 | |
| 183 | func 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 | |
| 195 | func 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 | |
| 212 | func 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. |
| 244 | func 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. |
| 262 | func 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. |
| 280 | func 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. |
| 294 | func 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. |
| 305 | func 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. |
| 315 | func (a *App) runningFinished() bool { |
| 316 | finished, _ := a.running.run.Done() |
| 317 | return finished |
| 318 | } |
| 319 | |
| 320 | // outputLines returns what the showing dialog holds. |
| 321 | func outputLines(a *App) []string { return a.running.list.Items() } |
| 322 | |
| 323 | func 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 | |
| 337 | func 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 | |
| 353 | func 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 | |
| 373 | func 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 | |
| 390 | func 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 | |
| 404 | func 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 | |
| 423 | func 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 | |
| 441 | func 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 | |
| 453 | func 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 | |
| 468 | func 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. |
| 480 | func 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 | |
| 496 | func 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 | } |