]> git.giacomolli.uk - onechat/blob - terminal_test.go
Separate multiline Go statements
[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
100 require.Equal(t, text+"\n[BlueBird]\n"+text+"\n", out.String())
101 }
102
103 func TestTerminalFileOutputKeepsMarkdown(t *testing.T) {
104 out, err := os.CreateTemp(t.TempDir(), "output")
105 require.NoError(t, err)
106
107 defer func() {
108 require.NoError(t, out.Close())
109 }()
110
111 text := "**plain**\n---\n`code`"
112 terminalTestShowEvents(t, out, Event{
113 Kind: "reply",
114 Text: text,
115 })
116
117 require.Zero(t, terminalColumns(out))
118
119 contents, err := os.ReadFile(out.Name())
120 require.NoError(t, err)
121 require.Equal(t, text+"\n", string(contents))
122 }
123
124 func TestTerminalStylesOnlyRepliesAndWork(t *testing.T) {
125 fd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0)
126 require.NoError(t, err)
127
128 defer func() {
129 require.NoError(t, unix.Close(fd))
130 }()
131
132 require.NoError(t, unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{Col: 12}))
133
134 out := TerminalTestOutput{fd: fd}
135 require.Equal(t, 12, terminalColumns(&out))
136 terminalTestShowEvents(t, &out,
137 Event{Kind: "busy"},
138 Event{
139 Kind: "reply",
140 Text: "**first next last**",
141 },
142 Event{
143 Kind: "work",
144 Text: "[BlueBird]\n# Report",
145 },
146 Event{
147 Kind: "tool",
148 Text: "**raw tool**",
149 },
150 Event{
151 Kind: "echo",
152 Text: "**raw result**",
153 },
154 Event{
155 Kind: "error",
156 Text: "**raw error**",
157 },
158 Event{
159 Kind: "notice",
160 Text: "**raw notice**",
161 },
162 Event{
163 Kind: "user",
164 Text: "**raw input**",
165 Source: "telegram",
166 },
167 Event{
168 Kind: "ready",
169 Text: "**raw agent**",
170 },
171 Event{
172 Kind: "subagent",
173 Text: "BlueBird",
174 },
175 Event{Kind: "busy"},
176 Event{Kind: "idle"},
177 )
178
179 require.Equal(t, "[working]\n\x1b[1mfirst next\x1b[0m\n\x1b[1mlast\x1b[0m\n[BlueBird]\n\x1b[1mReport\x1b[0m\n"+
180 "[tool] **raw tool**\n[result] **raw result**\n[error] **raw error**\n**raw notice**\n[telegram] **raw input**\n"+
181 "[connected to **raw agent**]\n[BlueBird] started\n[working]\n[done]\n> ", out.String())
182 }
183
184 func TestTerminalZeroWidthUsesEightyAndReadsChanges(t *testing.T) {
185 fd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0)
186 require.NoError(t, err)
187
188 defer func() {
189 require.NoError(t, unix.Close(fd))
190 }()
191
192 out := TerminalTestOutput{fd: fd}
193 require.NoError(t, unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{Col: 0}))
194 require.Equal(t, 80, terminalColumns(&out))
195 terminalTestShowEvents(t, &out, Event{
196 Kind: "reply",
197 Text: strings.Repeat("a", 81),
198 })
199
200 require.Equal(t, strings.Repeat("a", 80)+"\na\n> ", out.String())
201 require.NoError(t, unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{Col: 40}))
202 require.Equal(t, 40, terminalColumns(&out))
203 }
204
205 func TestTerminalPromptsAfterIdleEventsAndClearsBeforeShownEvents(t *testing.T) {
206 fd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0)
207 require.NoError(t, err)
208
209 defer func() {
210 require.NoError(t, unix.Close(fd))
211 }()
212
213 out := TerminalTestOutput{fd: fd}
214 terminalTestShowEvents(t, &out,
215 Event{
216 Kind: "ready",
217 Text: "Example",
218 },
219 Event{
220 Kind: "user",
221 Source: terminalSource,
222 Text: "hidden",
223 },
224 Event{Kind: "unknown"},
225 Event{
226 Kind: "notice",
227 Text: "Idle notice",
228 },
229 Event{
230 Kind: "work",
231 Text: "**Idle report**",
232 },
233 Event{Kind: "busy"},
234 Event{
235 Kind: "notice",
236 Text: "Busy notice",
237 },
238 Event{
239 Kind: "reply",
240 Text: "**Answer**",
241 },
242 Event{Kind: "idle"},
243 Event{
244 Kind: "error",
245 Text: "Idle error",
246 },
247 )
248
249 require.Equal(t, "[connected to Example]\n> "+
250 "\r\x1b[2KIdle notice\n> "+
251 "\r\x1b[2K\x1b[1mIdle report\x1b[0m\n> "+
252 "\r\x1b[2K[working]\nBusy notice\n\x1b[1mAnswer\x1b[0m\n[done]\n> "+
253 "\r\x1b[2K[error] Idle error\n> ", out.String())
254 }
255
256 func TestTerminalNonterminalEventsHaveNoPromptOrClearCommands(t *testing.T) {
257 var out strings.Builder
258
259 terminalTestShowEvents(t, &out,
260 Event{
261 Kind: "ready",
262 Text: "Example",
263 },
264 Event{
265 Kind: "notice",
266 Text: "Idle notice",
267 },
268 Event{Kind: "busy"},
269 Event{
270 Kind: "notice",
271 Text: "Busy notice",
272 },
273 Event{
274 Kind: "reply",
275 Text: "**Answer**",
276 },
277 Event{Kind: "idle"},
278 Event{
279 Kind: "notice",
280 Text: "Idle again",
281 },
282 )
283
284 require.Equal(t, "[connected to Example]\nIdle notice\n[working]\nBusy notice\n**Answer**\n[done]\nIdle again\n", out.String())
285 }
286
287 // TerminalTestOutput captures text while it supplies a test terminal
288 // descriptor.
289 type TerminalTestOutput struct {
290 strings.Builder
291 fd int
292 }
293
294 func (o *TerminalTestOutput) Fd() uintptr {
295 return uintptr(o.fd)
296 }
297
298 func terminalTestShowEvents(t *testing.T, out io.Writer, events ...Event) {
299 t.Helper()
300
301 server, client := net.Pipe()
302 done := make(chan error, 1)
303 go func() {
304 done <- terminalShowEvents(client, out)
305 }()
306
307 encoder := json.NewEncoder(server)
308 for _, event := range events {
309 require.NoError(t, encoder.Encode(event))
310 }
311
312 require.NoError(t, server.Close())
313 require.NoError(t, <-done)
314 require.NoError(t, client.Close())
315 }