]> git.giacomolli.uk - onechat/blob - terminal_test.go
Give the root tools to start and message children
[onechat] / terminal_test.go
1 package main
2
3 import (
4         "encoding/json"
5         "io"
6         "net"
7         "os"
8         "path/filepath"
9         "strings"
10         "testing"
11
12         "github.com/stretchr/testify/require"
13         "golang.org/x/sys/unix"
14 )
15
16 func TestClientEndsWhenTheDaemonEnds(t *testing.T) {
17         dir := t.TempDir()
18         listener, err := daemonListenSocket(filepath.Join(dir, socketFileName))
19         require.NoError(t, err)
20
21         accepted := make(chan net.Conn)
22         go func() {
23                 conn, err := listener.Accept()
24                 if err == nil {
25                         accepted <- conn
26                 }
27         }()
28
29         conn, err := net.Dial("unix", filepath.Join(dir, socketFileName))
30         require.NoError(t, err)
31
32         in, _ := io.Pipe()
33         done := make(chan error)
34         go func() {
35                 done <- terminalSession(t.Context(), conn, in, io.Discard)
36         }()
37
38         require.NoError(t, (<-accepted).Close())
39         require.ErrorIs(t, <-done, errTerminalDaemonClosed)
40         require.NoError(t, listener.Close())
41 }
42
43 func TestEventText(t *testing.T) {
44         testCases := []struct {
45                 name  string
46                 event Event
47                 text  string
48         }{
49                 {
50                         name:  "reply as it is",
51                         event: Event{Kind: "reply", Text: "Done."},
52                         text:  "Done.",
53                 },
54                 {
55                         name:  "own user message not shown",
56                         event: Event{Kind: "user", Text: "typed", Source: terminalSource},
57                         text:  "",
58                 },
59                 {
60                         name:  "user message of another source marked",
61                         event: Event{Kind: "user", Text: "from the phone", Source: "telegram"},
62                         text:  "[telegram] from the phone",
63                 },
64                 {
65                         name:  "start of a subagent",
66                         event: Event{Kind: "subagent", Text: "GreenOtter"},
67                         text:  "[GreenOtter] started",
68                 },
69                 {
70                         name:  "report of a subagent whole",
71                         event: Event{Kind: "work", Text: "[GreenOtter]\nThere are 3 files.\n\nFull chat: zoom(\"GreenOtter\")", Source: "GreenOtter", Status: subagentFinished},
72                         text:  "[GreenOtter]\nThere are 3 files.\n\nFull chat: zoom(\"GreenOtter\")",
73                 },
74                 {
75                         name:  "long result cut",
76                         event: Event{Kind: "echo", Text: strings.Repeat("line\n", 12)},
77                         text:  "[result] " + strings.Repeat("line\n", 9) + "line\n[2 more lines]",
78                 },
79         }
80
81         for _, tc := range testCases {
82                 t.Run(tc.name, func(t *testing.T) {
83                         require.Equal(t, tc.text, terminalEventText(tc.event))
84                 })
85         }
86 }
87
88 func TestTerminalEventsKeepMarkdownInNonterminalOutput(t *testing.T) {
89         var out strings.Builder
90
91         text := "# Heading\n\n**bold** and `code`\nA | B\n--- | ---\nx | y"
92         terminalTestShowEvents(t, &out, Event{
93                 Kind: "reply",
94                 Text: text,
95         }, Event{
96                 Kind: "work",
97                 Text: "[BlueBird]\n" + text,
98         })
99         require.Equal(t, text+"\n[BlueBird]\n"+text+"\n", out.String())
100 }
101
102 func TestTerminalFileOutputKeepsMarkdown(t *testing.T) {
103         out, err := os.CreateTemp(t.TempDir(), "output")
104         require.NoError(t, err)
105
106         defer func() {
107                 require.NoError(t, out.Close())
108         }()
109
110         text := "**plain**\n---\n`code`"
111         terminalTestShowEvents(t, out, Event{
112                 Kind: "reply",
113                 Text: text,
114         })
115         require.Zero(t, terminalColumns(out))
116
117         contents, err := os.ReadFile(out.Name())
118         require.NoError(t, err)
119         require.Equal(t, text+"\n", string(contents))
120 }
121
122 func TestTerminalStylesOnlyRepliesAndWork(t *testing.T) {
123         fd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0)
124         require.NoError(t, err)
125
126         defer func() {
127                 require.NoError(t, unix.Close(fd))
128         }()
129
130         require.NoError(t, unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{Col: 12}))
131
132         out := TerminalTestOutput{fd: fd}
133         require.Equal(t, 12, terminalColumns(&out))
134         terminalTestShowEvents(t, &out,
135                 Event{Kind: "busy"},
136                 Event{
137                         Kind: "reply",
138                         Text: "**first next last**",
139                 },
140                 Event{
141                         Kind: "work",
142                         Text: "[BlueBird]\n# Report",
143                 },
144                 Event{
145                         Kind: "tool",
146                         Text: "**raw tool**",
147                 },
148                 Event{
149                         Kind: "echo",
150                         Text: "**raw result**",
151                 },
152                 Event{
153                         Kind: "error",
154                         Text: "**raw error**",
155                 },
156                 Event{
157                         Kind: "notice",
158                         Text: "**raw notice**",
159                 },
160                 Event{
161                         Kind:   "user",
162                         Text:   "**raw input**",
163                         Source: "telegram",
164                 },
165                 Event{
166                         Kind: "ready",
167                         Text: "**raw agent**",
168                 },
169                 Event{
170                         Kind: "subagent",
171                         Text: "BlueBird",
172                 },
173                 Event{Kind: "busy"},
174                 Event{Kind: "idle"},
175         )
176         require.Equal(t, "[working]\n\x1b[1mfirst next\x1b[0m\n\x1b[1mlast\x1b[0m\n[BlueBird]\n\x1b[1mReport\x1b[0m\n"+
177                 "[tool] **raw tool**\n[result] **raw result**\n[error] **raw error**\n**raw notice**\n[telegram] **raw input**\n"+
178                 "[connected to **raw agent**]\n[BlueBird] started\n[working]\n[done]\n> ", out.String())
179 }
180
181 func TestTerminalZeroWidthUsesEightyAndReadsChanges(t *testing.T) {
182         fd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0)
183         require.NoError(t, err)
184
185         defer func() {
186                 require.NoError(t, unix.Close(fd))
187         }()
188
189         out := TerminalTestOutput{fd: fd}
190         require.NoError(t, unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{Col: 0}))
191         require.Equal(t, 80, terminalColumns(&out))
192         terminalTestShowEvents(t, &out, Event{
193                 Kind: "reply",
194                 Text: strings.Repeat("a", 81),
195         })
196         require.Equal(t, strings.Repeat("a", 80)+"\na\n> ", out.String())
197         require.NoError(t, unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{Col: 40}))
198         require.Equal(t, 40, terminalColumns(&out))
199 }
200
201 func TestTerminalPromptsAfterIdleEventsAndClearsBeforeShownEvents(t *testing.T) {
202         fd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0)
203         require.NoError(t, err)
204
205         defer func() {
206                 require.NoError(t, unix.Close(fd))
207         }()
208
209         out := TerminalTestOutput{fd: fd}
210         terminalTestShowEvents(t, &out,
211                 Event{
212                         Kind: "ready",
213                         Text: "Example",
214                 },
215                 Event{
216                         Kind:   "user",
217                         Source: terminalSource,
218                         Text:   "hidden",
219                 },
220                 Event{Kind: "unknown"},
221                 Event{
222                         Kind: "notice",
223                         Text: "Idle notice",
224                 },
225                 Event{
226                         Kind: "work",
227                         Text: "**Idle report**",
228                 },
229                 Event{Kind: "busy"},
230                 Event{
231                         Kind: "notice",
232                         Text: "Busy notice",
233                 },
234                 Event{
235                         Kind: "reply",
236                         Text: "**Answer**",
237                 },
238                 Event{Kind: "idle"},
239                 Event{
240                         Kind: "error",
241                         Text: "Idle error",
242                 },
243         )
244         require.Equal(t, "[connected to Example]\n> "+
245                 "\r\x1b[2KIdle notice\n> "+
246                 "\r\x1b[2K\x1b[1mIdle report\x1b[0m\n> "+
247                 "\r\x1b[2K[working]\nBusy notice\n\x1b[1mAnswer\x1b[0m\n[done]\n> "+
248                 "\r\x1b[2K[error] Idle error\n> ", out.String())
249 }
250
251 func TestTerminalNonterminalEventsHaveNoPromptOrClearCommands(t *testing.T) {
252         var out strings.Builder
253
254         terminalTestShowEvents(t, &out,
255                 Event{
256                         Kind: "ready",
257                         Text: "Example",
258                 },
259                 Event{
260                         Kind: "notice",
261                         Text: "Idle notice",
262                 },
263                 Event{Kind: "busy"},
264                 Event{
265                         Kind: "notice",
266                         Text: "Busy notice",
267                 },
268                 Event{
269                         Kind: "reply",
270                         Text: "**Answer**",
271                 },
272                 Event{Kind: "idle"},
273                 Event{
274                         Kind: "notice",
275                         Text: "Idle again",
276                 },
277         )
278         require.Equal(t, "[connected to Example]\nIdle notice\n[working]\nBusy notice\n**Answer**\n[done]\nIdle again\n", out.String())
279 }
280
281 // TerminalTestOutput captures text while it supplies a test terminal
282 // descriptor.
283 type TerminalTestOutput struct {
284         strings.Builder
285         fd int
286 }
287
288 func (o *TerminalTestOutput) Fd() uintptr {
289         return uintptr(o.fd)
290 }
291
292 func terminalTestShowEvents(t *testing.T, out io.Writer, events ...Event) {
293         t.Helper()
294
295         server, client := net.Pipe()
296         done := make(chan error, 1)
297         go func() {
298                 done <- terminalShowEvents(client, out)
299         }()
300
301         encoder := json.NewEncoder(server)
302         for _, event := range events {
303                 require.NoError(t, encoder.Encode(event))
304         }
305
306         require.NoError(t, server.Close())
307         require.NoError(t, <-done)
308         require.NoError(t, client.Close())
309 }