turbo-editors/turbo-corepublic Fork 0
28d59854361aeda8541d853093e732126f3d7bff
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.

pty_test.go · 320 lines · 8.6 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 20h ago1package terminal
2
3import (
4 "errors"
5 "io"
6 "os"
7 "path/filepath"
8 "runtime"
9 "strings"
10 "testing"
11 "time"
12)
13
14// skipWithoutPTY skips a test where a pseudo-terminal cannot be opened.
15func skipWithoutPTY(t *testing.T) {
16 t.Helper()
17
18 // These tests speak to /bin/sh and stty; Windows has a pseudo-console
19 // but neither of those, so they are Unix tests whatever the platform
20 // supports.
21 if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
22 t.Skipf("these tests drive a Unix shell; not on %s", runtime.GOOS)
23 }
24 if _, err := os.Stat("/dev/ptmx"); err != nil {
25 t.Skipf("no /dev/ptmx here: %v", err)
26 }
27}
28
29// startSession opens a session running a bare shell, closed when the test ends.
30func startSession(t *testing.T, options Options) *Session {
31 t.Helper()
32 skipWithoutPTY(t)
33
34 if options.Shell == "" {
35 options.Shell = "/bin/sh"
36 }
37 if options.Width == 0 {
38 options.Width, options.Height = 80, 24
39 }
40
41 session, err := Start(options)
42 if err != nil {
43 t.Fatalf("Start() error = %v", err)
44 }
45 t.Cleanup(func() { session.Close() })
46 return session
47}
48
49// readUntil reads from a session until the text appears or time runs out. It
50// returns everything read, so a failure can show what did arrive.
51func readUntil(t *testing.T, session *Session, want string) string {
52 t.Helper()
53
54 found := make(chan string, 1)
55 go func() {
56 var seen strings.Builder
57 buffer := make([]byte, 4096)
58 for {
59 n, err := session.Read(buffer)
60 if n > 0 {
61 seen.Write(buffer[:n])
62 if strings.Contains(seen.String(), want) {
63 found <- seen.String()
64 return
65 }
66 }
67 if err != nil {
68 found <- seen.String()
69 return
70 }
71 }
72 }()
73
74 select {
75 case seen := <-found:
76 if !strings.Contains(seen, want) {
77 t.Fatalf("the shell never wrote %q; it wrote %q", want, seen)
78 }
79 return seen
80 case <-time.After(10 * time.Second):
81 t.Fatalf("timed out waiting for %q", want)
82 return ""
83 }
84}
85
86func TestAShellRunsAndItsOutputComesBack(t *testing.T) {
87 session := startSession(t, Options{})
88
89 if _, err := session.Write([]byte("echo hello-from-the-pty\n")); err != nil {
90 t.Fatalf("Write() error = %v", err)
91 }
92
93 readUntil(t, session, "hello-from-the-pty")
94}
95
96func TestTheShellStartsInTheDirectoryItWasGiven(t *testing.T) {
97 directory := t.TempDir()
98 // macOS reports /tmp through a symlink, so the name is what can be checked
99 // rather than the whole path.
100 marker := filepath.Base(directory)
101 session := startSession(t, Options{Dir: directory})
102
103 session.Write([]byte("pwd\n")) //nolint:errcheck
104
105 readUntil(t, session, marker)
106}
107
108func TestTheShellIsToldWhichTerminalItHas(t *testing.T) {
109 session := startSession(t, Options{})
110
111 session.Write([]byte("echo \"[$TERM]\"\n")) //nolint:errcheck
112
113 readUntil(t, session, "["+TermName+"]")
114}
115
116func TestTheShellIsToldItsSize(t *testing.T) {
117 session := startSession(t, Options{Width: 100, Height: 40})
118
119 // stty reads the size from the terminal itself, so this is the size the
120 // kernel really recorded rather than the one we asked for.
121 session.Write([]byte("stty size\n")) //nolint:errcheck
122
123 readUntil(t, session, "40 100")
124}
125
126func TestResizeIsPassedOnToTheShell(t *testing.T) {
127 session := startSession(t, Options{Width: 80, Height: 24})
128 session.Write([]byte("echo ready\n")) //nolint:errcheck
129 readUntil(t, session, "ready")
130
131 if err := session.Resize(132, 50); err != nil {
132 t.Fatalf("Resize() error = %v", err)
133 }
134 session.Write([]byte("stty size\n")) //nolint:errcheck
135
136 readUntil(t, session, "50 132")
137}
138
139func TestResizeRefusesNothingSmallerThanOneCell(t *testing.T) {
140 session := startSession(t, Options{})
141
142 if err := session.Resize(0, -5); err != nil {
143 t.Errorf("Resize() error = %v, want a size below one to be raised rather than refused", err)
144 }
145}
146
147func TestTheShellSeesATerminalAndNotAPipe(t *testing.T) {
148 // This is the whole point of a pseudo-terminal: a shell behind a pipe
149 // turns off its prompt, its colours and its job control.
150 session := startSession(t, Options{})
151
152 session.Write([]byte("test -t 0 && echo is-a-terminal\n")) //nolint:errcheck
153
154 readUntil(t, session, "is-a-terminal")
155}
156
157func TestCommandNamesTheShell(t *testing.T) {
158 session := startSession(t, Options{Shell: "/bin/sh"})
159
160 if got := session.Command(); got != "sh" {
161 t.Errorf("Command() = %q, want %q", got, "sh")
162 }
163}
164
165func TestClosingEndsTheShell(t *testing.T) {
166 session := startSession(t, Options{})
167 session.Write([]byte("echo started\n")) //nolint:errcheck
168 readUntil(t, session, "started")
169
170 if err := session.Close(); err != nil {
171 t.Errorf("Close() error = %v", err)
172 }
173
174 // Reading a closed session must fail rather than block for ever.
175 done := make(chan struct{})
176 go func() {
177 session.Read(make([]byte, 16)) //nolint:errcheck
178 close(done)
179 }()
180
181 select {
182 case <-done:
183 case <-time.After(5 * time.Second):
184 t.Error("reading a closed session did not return")
185 }
186}
187
188func TestTheOutputOfARealShellDrivesTheEmulator(t *testing.T) {
189 // The three pieces together: a shell writes escape sequences into a
190 // pseudo-terminal, the parser reads them, and the screen shows the result.
191 session := startSession(t, Options{Width: 40, Height: 10})
192 screen := NewScreen(40, 10)
193 parser := NewParser(screen)
194
195 // clear puts the cursor home and erases; the text then lands on row 0
196 // wherever the prompt had got to.
197 session.Write([]byte("clear; printf 'plain \\033[1;31mred\\033[0m\\n'\n")) //nolint:errcheck
198 readUntil(t, session, "red")
199
200 // Everything the shell has written by now is replayed into the emulator.
201 session.Write([]byte("exit\n")) //nolint:errcheck
202 drain(t, session, parser)
203
204 if !screenContains(screen, "plain red") {
205 t.Errorf("the screen never showed the line; it shows:\n%s", screenText(screen))
206 }
207}
208
209func TestStartRefusesAShellThatIsNotThere(t *testing.T) {
210 skipWithoutPTY(t)
211
212 _, err := Start(Options{Shell: "/nonexistent/shell", Width: 20, Height: 5})
213
214 if err == nil {
215 t.Fatal("Start() error = nil for a shell that does not exist")
216 }
217}
218
219func TestUnsupportedPlatformsSaySo(t *testing.T) {
220 if runtime.GOOS == "linux" || runtime.GOOS == "darwin" || runtime.GOOS == "windows" {
221 t.Skip("this platform is supported")
222 }
223
224 _, err := Start(Options{Width: 20, Height: 5})
225
226 if !errors.Is(err, ErrUnsupported) {
227 t.Errorf("Start() error = %v, want ErrUnsupported", err)
228 }
229}
230
231func TestTheEnvironmentAlwaysCarriesOurTerm(t *testing.T) {
232 got := environment([]string{"PATH=/bin", "TERM=something-else", "HOME=/tmp"})
233
234 var terms []string
235 for _, entry := range got {
236 if strings.HasPrefix(entry, "TERM=") {
237 terms = append(terms, entry)
238 }
239 }
240
241 if len(terms) != 1 || terms[0] != "TERM="+TermName {
242 t.Errorf("the environment carries %v, want exactly TERM=%s", terms, TermName)
243 }
244 if !slicesContain(got, "PATH=/bin") || !slicesContain(got, "HOME=/tmp") {
245 t.Errorf("the environment lost entries it should have kept: %v", got)
246 }
247}
248
249// drain reads whatever is left of a session into a parser, until the shell has
250// gone or time runs out.
251func drain(t *testing.T, session *Session, parser *Parser) {
252 t.Helper()
253
254 done := make(chan struct{})
255 go func() {
256 io.Copy(parser, session) //nolint:errcheck
257 close(done)
258 }()
259
260 select {
261 case <-done:
262 case <-time.After(10 * time.Second):
263 t.Fatal("the shell did not finish")
264 }
265}
266
267// screenContains reports whether any row of a screen holds the text.
268func screenContains(s *Screen, text string) bool {
269 _, height := s.Size()
270 for row := range height {
271 if strings.Contains(s.LineText(row), text) {
272 return true
273 }
274 }
275 return false
276}
277
278// screenText renders a whole screen, for a failure message.
279func screenText(s *Screen) string {
280 _, height := s.Size()
281 rows := make([]string, height)
282 for row := range height {
283 rows[row] = s.LineText(row)
284 }
285 return strings.Join(rows, "\n")
286}
287
288// slicesContain reports whether a slice holds a value.
289func slicesContain(list []string, want string) bool {
290 for _, item := range list {
291 if item == want {
292 return true
293 }
294 }
295 return false
296}
297
298func TestArgsRunOneCommandRatherThanAShell(t *testing.T) {
299 // This is what lets a menu item run "go test ./..." in a window instead of
300 // dropping the user into a shell and leaving them to type it.
301 session := startSession(t, Options{
302 Shell: "/bin/sh",
303 Args: []string{"-c", "echo one''-command-ran"},
304 })
305
306 if got := readUntil(t, session, "one-command-ran"); !strings.Contains(got, "one-command-ran") {
307 t.Errorf("the command did not run; the session gave %q", got)
308 }
309}
310
311func TestASessionWithNoArgsIsStillAnInteractiveShell(t *testing.T) {
312 session := startSession(t, Options{Shell: "/bin/sh"})
313
314 if _, err := session.Write([]byte("echo still''-interactive\r")); err != nil {
315 t.Fatalf("writing to the shell: %v", err)
316 }
317 if got := readUntil(t, session, "still-interactive"); !strings.Contains(got, "still-interactive") {
318 t.Errorf("the shell is not interactive; it gave %q", got)
319 }
320}