package app import ( "os" "path/filepath" "strings" "testing" "time" "github.com/gdamore/tcell/v2" ) // newWatchedFile returns an editor with a file open in the front window, and // that file's path. The contents written later are deliberately of a different // length: on a filesystem that stamps modification times by the second, two // writes in the same second are told apart by size or not at all. func newWatchedFile(t *testing.T) (*App, tcell.SimulationScreen, string) { t.Helper() a, screen := newTestApp(t) path := filepath.Join(t.TempDir(), "main.go") writeTestFile(t, path, "package main\n") a.Open(path) a.tick() // the loop has seen the file as it was opened return a, screen, path } func TestAFileRewrittenByAnotherProgramIsReloaded(t *testing.T) { // This is the whole feature: a formatter, a generator or an agent in // another terminal rewrites the file, and the window shows it without // closing and reopening. a, _, path := newWatchedFile(t) writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n") a.tick() if got := activeEditorText(t, a, path); !strings.Contains(got, "rewritten outside") { t.Errorf("the window still shows %q after the file changed on disk", got) } if got := a.status.Message(); got != "Reloaded main.go" { t.Errorf("status message = %q, want %q", got, "Reloaded main.go") } if title := a.desktop.Active().Title(); title != "main.go" { t.Errorf("window title = %q after a reload, want %q", title, "main.go") } } func TestAFileWithUnsavedChangesIsKeptWhenTheDiskChanges(t *testing.T) { // The user's work and the other program's genuinely conflict, and the // editor is not the one to decide — so it keeps the work and says so. a, _, path := newWatchedFile(t) typeText(a, "// mine") edited := activeEditorText(t, a, path) writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n") a.tick() if got := activeEditorText(t, a, path); got != edited { t.Errorf("the modified buffer was reloaded: %q, want the unsaved edit %q", got, edited) } if got := a.status.Message(); !strings.Contains(got, "changed on disk") { t.Errorf("status message = %q, want it to say the file changed on disk", got) } } func TestAChangeOnDiskIsReportedOncePerChange(t *testing.T) { // The message would otherwise be re-issued on every turn of the loop for as // long as the window stays modified, hiding everything else the status bar // has to say. a, _, path := newWatchedFile(t) typeText(a, "// mine") writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n") a.tick() a.Message("something else") a.tick() if got := a.status.Message(); got != "something else" { t.Errorf("status message = %q; the change on disk was reported a second time", got) } } func TestTheEditorsOwnSaveIsNotMistakenForAChangeOnDisk(t *testing.T) { // A save rewrites the file — through a rename, so with a new inode and // stamp — and must not come round as a reload of the text just written. a, _, path := newWatchedFile(t) typeText(a, "// mine") a.SaveFile() a.tick() if got := a.status.Message(); got != "Saved main.go" { t.Errorf("status message = %q after a save, want %q", got, "Saved main.go") } if got := activeEditorText(t, a, path); !strings.Contains(got, "// mine") { t.Errorf("the saved text was lost: %q", got) } } func TestAFileThatDisappearsIsLeftAsItWasUntilItComesBack(t *testing.T) { // Some programs replace a file by deleting it and writing a new one; a // stat between the two sees nothing. The window keeps what it has, and // picks the file up when it is there again. a, _, path := newWatchedFile(t) if err := os.Remove(path); err != nil { t.Fatal(err) } a.tick() if got := activeEditorText(t, a, path); got != "package main\n" { t.Errorf("the window lost its text when the file went: %q", got) } writeTestFile(t, path, "package main\n\n// back again, longer\n") a.tick() if got := activeEditorText(t, a, path); !strings.Contains(got, "back again") { t.Errorf("the window did not pick the file up when it came back: %q", got) } } func TestAReloadTellsTheLanguageServerWhatTheFileNowSays(t *testing.T) { // A server answering completions from the text before the formatter ran // is the same stale-copy problem in another place. a, server, project := newCodeApp(t) path := filepath.Join(project, "main.go") waitForMethod(t, server, "textDocument/didOpen") a.tick() before := server.methodCount("textDocument/didChange") writeTestFile(t, path, "package main\n\nfunc main() { println() }\n") a.tick() deadline := time.After(2 * time.Second) for server.methodCount("textDocument/didChange") == before { select { case <-deadline: t.Fatal("the server was never told the reloaded text") case <-time.After(time.Millisecond): } } } func TestAClosedWindowsFileIsNoLongerWatched(t *testing.T) { a, _, path := newWatchedFile(t) a.CloseFile() a.tick() writeTestFile(t, path, "package main\n\n// nobody is looking\n") if len(a.fileStamps) != 0 { t.Errorf("%d file(s) still stamped after the only window closed", len(a.fileStamps)) } if a.fileWatch.changed() { t.Error("the watcher would wake the loop for a file no window shows") } } func TestTheWatcherWakesTheLoopWhenAnOpenFileChanges(t *testing.T) { // Run sits in PollEvent; without this wake the reload would wait for the // next keystroke, which is the defect being fixed. a, screen, path := newWatchedFile(t) a.fileWatchInterval = 5 * time.Millisecond stop := a.watchFiles() defer stop() writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n") if !wokeWithin(screen, time.Second) { t.Fatal("the event loop was never woken for a file that changed on disk") } } func TestTheWatcherLeavesAnIdleEditorAlone(t *testing.T) { // Waking the loop every second regardless would make the editor redraw // forever with nothing to show; the wake is for a change, not for time. a, screen, _ := newWatchedFile(t) a.fileWatchInterval = 5 * time.Millisecond stop := a.watchFiles() defer stop() if wokeWithin(screen, 100*time.Millisecond) { t.Fatal("the event loop was woken though no file changed") } } // wokeWithin reports whether the watcher posted its wake-up to the screen // before the deadline. func wokeWithin(screen tcell.SimulationScreen, within time.Duration) bool { deadline := time.After(within) for { for screen.HasPendingEvent() { if _, ok := screen.PollEvent().(*tcell.EventInterrupt); ok { return true } } select { case <-deadline: return false case <-time.After(2 * time.Millisecond): } } }