]> git.giacomolli.uk - onechat/blob - terminal_test.go
Show styled replies only on terminal output
[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{
136                         Kind: "reply",
137                         Text: "**first next last**",
138                 },
139                 Event{
140                         Kind: "work",
141                         Text: "[BlueBird]\n# Report",
142                 },
143                 Event{
144                         Kind: "tool",
145                         Text: "**raw tool**",
146                 },
147                 Event{
148                         Kind: "echo",
149                         Text: "**raw result**",
150                 },
151                 Event{
152                         Kind: "error",
153                         Text: "**raw error**",
154                 },
155                 Event{
156                         Kind: "notice",
157                         Text: "**raw notice**",
158                 },
159                 Event{
160                         Kind:   "user",
161                         Text:   "**raw input**",
162                         Source: "telegram",
163                 },
164                 Event{
165                         Kind: "ready",
166                         Text: "**raw agent**",
167                 },
168                 Event{
169                         Kind: "subagent",
170                         Text: "BlueBird",
171                 },
172                 Event{Kind: "busy"},
173                 Event{Kind: "idle"},
174         )
175         require.Equal(t, "\x1b[1mfirst next\x1b[0m\n\x1b[1mlast\x1b[0m\n[BlueBird]\n\x1b[1mReport\x1b[0m\n"+
176                 "[tool] **raw tool**\n[result] **raw result**\n[error] **raw error**\n**raw notice**\n[telegram] **raw input**\n"+
177                 "[connected to **raw agent**]\n[BlueBird] started\n[working]\n[done]\n", out.String())
178 }
179
180 func TestTerminalZeroWidthUsesEightyAndReadsChanges(t *testing.T) {
181         fd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0)
182         require.NoError(t, err)
183
184         defer func() {
185                 require.NoError(t, unix.Close(fd))
186         }()
187
188         out := TerminalTestOutput{fd: fd}
189         require.NoError(t, unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{Col: 0}))
190         require.Equal(t, 80, terminalColumns(&out))
191         terminalTestShowEvents(t, &out, Event{
192                 Kind: "reply",
193                 Text: strings.Repeat("a", 81),
194         })
195         require.Equal(t, strings.Repeat("a", 80)+"\na\n", out.String())
196         require.NoError(t, unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{Col: 40}))
197         require.Equal(t, 40, terminalColumns(&out))
198 }
199
200 // TerminalTestOutput captures text while it supplies a test terminal
201 // descriptor.
202 type TerminalTestOutput struct {
203         strings.Builder
204         fd int
205 }
206
207 func (o *TerminalTestOutput) Fd() uintptr {
208         return uintptr(o.fd)
209 }
210
211 func terminalTestShowEvents(t *testing.T, out io.Writer, events ...Event) {
212         t.Helper()
213
214         server, client := net.Pipe()
215         done := make(chan error, 1)
216         go func() {
217                 done <- terminalShowEvents(client, out)
218         }()
219
220         encoder := json.NewEncoder(server)
221         for _, event := range events {
222                 require.NoError(t, encoder.Encode(event))
223         }
224
225         require.NoError(t, server.Close())
226         require.NoError(t, <-done)
227         require.NoError(t, client.Close())
228 }