package terminal import ( "errors" "io" "os" "path/filepath" "runtime" "strings" "testing" "time" ) // skipWithoutPTY skips a test where a pseudo-terminal cannot be opened. func skipWithoutPTY(t *testing.T) { t.Helper() // These tests speak to /bin/sh and stty; Windows has a pseudo-console // but neither of those, so they are Unix tests whatever the platform // supports. if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { t.Skipf("these tests drive a Unix shell; not on %s", runtime.GOOS) } if _, err := os.Stat("/dev/ptmx"); err != nil { t.Skipf("no /dev/ptmx here: %v", err) } } // startSession opens a session running a bare shell, closed when the test ends. func startSession(t *testing.T, options Options) *Session { t.Helper() skipWithoutPTY(t) if options.Shell == "" { options.Shell = "/bin/sh" } if options.Width == 0 { options.Width, options.Height = 80, 24 } session, err := Start(options) if err != nil { t.Fatalf("Start() error = %v", err) } t.Cleanup(func() { session.Close() }) return session } // readUntil reads from a session until the text appears or time runs out. It // returns everything read, so a failure can show what did arrive. func readUntil(t *testing.T, session *Session, want string) string { t.Helper() found := make(chan string, 1) go func() { var seen strings.Builder buffer := make([]byte, 4096) for { n, err := session.Read(buffer) if n > 0 { seen.Write(buffer[:n]) if strings.Contains(seen.String(), want) { found <- seen.String() return } } if err != nil { found <- seen.String() return } } }() select { case seen := <-found: if !strings.Contains(seen, want) { t.Fatalf("the shell never wrote %q; it wrote %q", want, seen) } return seen case <-time.After(10 * time.Second): t.Fatalf("timed out waiting for %q", want) return "" } } func TestAShellRunsAndItsOutputComesBack(t *testing.T) { session := startSession(t, Options{}) if _, err := session.Write([]byte("echo hello-from-the-pty\n")); err != nil { t.Fatalf("Write() error = %v", err) } readUntil(t, session, "hello-from-the-pty") } func TestTheShellStartsInTheDirectoryItWasGiven(t *testing.T) { directory := t.TempDir() // macOS reports /tmp through a symlink, so the name is what can be checked // rather than the whole path. marker := filepath.Base(directory) session := startSession(t, Options{Dir: directory}) session.Write([]byte("pwd\n")) //nolint:errcheck readUntil(t, session, marker) } func TestTheShellIsToldWhichTerminalItHas(t *testing.T) { session := startSession(t, Options{}) session.Write([]byte("echo \"[$TERM]\"\n")) //nolint:errcheck readUntil(t, session, "["+TermName+"]") } func TestTheShellIsToldItsSize(t *testing.T) { session := startSession(t, Options{Width: 100, Height: 40}) // stty reads the size from the terminal itself, so this is the size the // kernel really recorded rather than the one we asked for. session.Write([]byte("stty size\n")) //nolint:errcheck readUntil(t, session, "40 100") } func TestResizeIsPassedOnToTheShell(t *testing.T) { session := startSession(t, Options{Width: 80, Height: 24}) session.Write([]byte("echo ready\n")) //nolint:errcheck readUntil(t, session, "ready") if err := session.Resize(132, 50); err != nil { t.Fatalf("Resize() error = %v", err) } session.Write([]byte("stty size\n")) //nolint:errcheck readUntil(t, session, "50 132") } func TestResizeRefusesNothingSmallerThanOneCell(t *testing.T) { session := startSession(t, Options{}) if err := session.Resize(0, -5); err != nil { t.Errorf("Resize() error = %v, want a size below one to be raised rather than refused", err) } } func TestTheShellSeesATerminalAndNotAPipe(t *testing.T) { // This is the whole point of a pseudo-terminal: a shell behind a pipe // turns off its prompt, its colours and its job control. session := startSession(t, Options{}) session.Write([]byte("test -t 0 && echo is-a-terminal\n")) //nolint:errcheck readUntil(t, session, "is-a-terminal") } func TestCommandNamesTheShell(t *testing.T) { session := startSession(t, Options{Shell: "/bin/sh"}) if got := session.Command(); got != "sh" { t.Errorf("Command() = %q, want %q", got, "sh") } } func TestClosingEndsTheShell(t *testing.T) { session := startSession(t, Options{}) session.Write([]byte("echo started\n")) //nolint:errcheck readUntil(t, session, "started") if err := session.Close(); err != nil { t.Errorf("Close() error = %v", err) } // Reading a closed session must fail rather than block for ever. done := make(chan struct{}) go func() { session.Read(make([]byte, 16)) //nolint:errcheck close(done) }() select { case <-done: case <-time.After(5 * time.Second): t.Error("reading a closed session did not return") } } func TestTheOutputOfARealShellDrivesTheEmulator(t *testing.T) { // The three pieces together: a shell writes escape sequences into a // pseudo-terminal, the parser reads them, and the screen shows the result. session := startSession(t, Options{Width: 40, Height: 10}) screen := NewScreen(40, 10) parser := NewParser(screen) // clear puts the cursor home and erases; the text then lands on row 0 // wherever the prompt had got to. session.Write([]byte("clear; printf 'plain \\033[1;31mred\\033[0m\\n'\n")) //nolint:errcheck readUntil(t, session, "red") // Everything the shell has written by now is replayed into the emulator. session.Write([]byte("exit\n")) //nolint:errcheck drain(t, session, parser) if !screenContains(screen, "plain red") { t.Errorf("the screen never showed the line; it shows:\n%s", screenText(screen)) } } func TestStartRefusesAShellThatIsNotThere(t *testing.T) { skipWithoutPTY(t) _, err := Start(Options{Shell: "/nonexistent/shell", Width: 20, Height: 5}) if err == nil { t.Fatal("Start() error = nil for a shell that does not exist") } } func TestUnsupportedPlatformsSaySo(t *testing.T) { if runtime.GOOS == "linux" || runtime.GOOS == "darwin" || runtime.GOOS == "windows" { t.Skip("this platform is supported") } _, err := Start(Options{Width: 20, Height: 5}) if !errors.Is(err, ErrUnsupported) { t.Errorf("Start() error = %v, want ErrUnsupported", err) } } func TestTheEnvironmentAlwaysCarriesOurTerm(t *testing.T) { got := environment([]string{"PATH=/bin", "TERM=something-else", "HOME=/tmp"}) var terms []string for _, entry := range got { if strings.HasPrefix(entry, "TERM=") { terms = append(terms, entry) } } if len(terms) != 1 || terms[0] != "TERM="+TermName { t.Errorf("the environment carries %v, want exactly TERM=%s", terms, TermName) } if !slicesContain(got, "PATH=/bin") || !slicesContain(got, "HOME=/tmp") { t.Errorf("the environment lost entries it should have kept: %v", got) } } // drain reads whatever is left of a session into a parser, until the shell has // gone or time runs out. func drain(t *testing.T, session *Session, parser *Parser) { t.Helper() done := make(chan struct{}) go func() { io.Copy(parser, session) //nolint:errcheck close(done) }() select { case <-done: case <-time.After(10 * time.Second): t.Fatal("the shell did not finish") } } // screenContains reports whether any row of a screen holds the text. func screenContains(s *Screen, text string) bool { _, height := s.Size() for row := range height { if strings.Contains(s.LineText(row), text) { return true } } return false } // screenText renders a whole screen, for a failure message. func screenText(s *Screen) string { _, height := s.Size() rows := make([]string, height) for row := range height { rows[row] = s.LineText(row) } return strings.Join(rows, "\n") } // slicesContain reports whether a slice holds a value. func slicesContain(list []string, want string) bool { for _, item := range list { if item == want { return true } } return false } func TestArgsRunOneCommandRatherThanAShell(t *testing.T) { // This is what lets a menu item run "go test ./..." in a window instead of // dropping the user into a shell and leaving them to type it. session := startSession(t, Options{ Shell: "/bin/sh", Args: []string{"-c", "echo one''-command-ran"}, }) if got := readUntil(t, session, "one-command-ran"); !strings.Contains(got, "one-command-ran") { t.Errorf("the command did not run; the session gave %q", got) } } func TestASessionWithNoArgsIsStillAnInteractiveShell(t *testing.T) { session := startSession(t, Options{Shell: "/bin/sh"}) if _, err := session.Write([]byte("echo still''-interactive\r")); err != nil { t.Fatalf("writing to the shell: %v", err) } if got := readUntil(t, session, "still-interactive"); !strings.Contains(got, "still-interactive") { t.Errorf("the shell is not interactive; it gave %q", got) } }