package main import ( "encoding/json" "io" "net" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/require" "golang.org/x/sys/unix" ) func TestClientEndsWhenTheDaemonEnds(t *testing.T) { dir := t.TempDir() listener, err := daemonListenSocket(filepath.Join(dir, socketFileName)) require.NoError(t, err) accepted := make(chan net.Conn) go func() { conn, err := listener.Accept() if err == nil { accepted <- conn } }() conn, err := net.Dial("unix", filepath.Join(dir, socketFileName)) require.NoError(t, err) in, _ := io.Pipe() done := make(chan error) go func() { done <- terminalSession(t.Context(), conn, in, io.Discard) }() require.NoError(t, (<-accepted).Close()) require.ErrorIs(t, <-done, errTerminalDaemonClosed) require.NoError(t, listener.Close()) } func TestEventText(t *testing.T) { testCases := []struct { name string event Event text string }{ { name: "reply as it is", event: Event{Kind: "reply", Text: "Done."}, text: "Done.", }, { name: "own user message not shown", event: Event{Kind: "user", Text: "typed", Source: terminalSource}, text: "", }, { name: "user message of another source marked", event: Event{Kind: "user", Text: "from the phone", Source: "telegram"}, text: "[telegram] from the phone", }, { name: "start of a subagent", event: Event{Kind: "subagent", Text: "GreenOtter"}, text: "[GreenOtter] started", }, { name: "report of a subagent whole", event: Event{Kind: "work", Text: "[GreenOtter]\nThere are 3 files.\n\nFull chat: zoom(\"GreenOtter\")", Source: "GreenOtter", Status: subagentFinished}, text: "[GreenOtter]\nThere are 3 files.\n\nFull chat: zoom(\"GreenOtter\")", }, { name: "long result cut", event: Event{Kind: "echo", Text: strings.Repeat("line\n", 12)}, text: "[result] " + strings.Repeat("line\n", 9) + "line\n[2 more lines]", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.text, terminalEventText(tc.event)) }) } } func TestTerminalEventsKeepMarkdownInNonterminalOutput(t *testing.T) { var out strings.Builder text := "# Heading\n\n**bold** and `code`\nA | B\n--- | ---\nx | y" terminalTestShowEvents(t, &out, Event{ Kind: "reply", Text: text, }, Event{ Kind: "work", Text: "[BlueBird]\n" + text, }) require.Equal(t, text+"\n[BlueBird]\n"+text+"\n", out.String()) } func TestTerminalFileOutputKeepsMarkdown(t *testing.T) { out, err := os.CreateTemp(t.TempDir(), "output") require.NoError(t, err) defer func() { require.NoError(t, out.Close()) }() text := "**plain**\n---\n`code`" terminalTestShowEvents(t, out, Event{ Kind: "reply", Text: text, }) require.Zero(t, terminalColumns(out)) contents, err := os.ReadFile(out.Name()) require.NoError(t, err) require.Equal(t, text+"\n", string(contents)) } func TestTerminalStylesOnlyRepliesAndWork(t *testing.T) { fd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0) require.NoError(t, err) defer func() { require.NoError(t, unix.Close(fd)) }() require.NoError(t, unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{Col: 12})) out := TerminalTestOutput{fd: fd} require.Equal(t, 12, terminalColumns(&out)) terminalTestShowEvents(t, &out, Event{ Kind: "reply", Text: "**first next last**", }, Event{ Kind: "work", Text: "[BlueBird]\n# Report", }, Event{ Kind: "tool", Text: "**raw tool**", }, Event{ Kind: "echo", Text: "**raw result**", }, Event{ Kind: "error", Text: "**raw error**", }, Event{ Kind: "notice", Text: "**raw notice**", }, Event{ Kind: "user", Text: "**raw input**", Source: "telegram", }, Event{ Kind: "ready", Text: "**raw agent**", }, Event{ Kind: "subagent", Text: "BlueBird", }, Event{Kind: "busy"}, Event{Kind: "idle"}, ) require.Equal(t, "\x1b[1mfirst next\x1b[0m\n\x1b[1mlast\x1b[0m\n[BlueBird]\n\x1b[1mReport\x1b[0m\n"+ "[tool] **raw tool**\n[result] **raw result**\n[error] **raw error**\n**raw notice**\n[telegram] **raw input**\n"+ "[connected to **raw agent**]\n[BlueBird] started\n[working]\n[done]\n", out.String()) } func TestTerminalZeroWidthUsesEightyAndReadsChanges(t *testing.T) { fd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0) require.NoError(t, err) defer func() { require.NoError(t, unix.Close(fd)) }() out := TerminalTestOutput{fd: fd} require.NoError(t, unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{Col: 0})) require.Equal(t, 80, terminalColumns(&out)) terminalTestShowEvents(t, &out, Event{ Kind: "reply", Text: strings.Repeat("a", 81), }) require.Equal(t, strings.Repeat("a", 80)+"\na\n", out.String()) require.NoError(t, unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{Col: 40})) require.Equal(t, 40, terminalColumns(&out)) } // TerminalTestOutput captures text while it supplies a test terminal // descriptor. type TerminalTestOutput struct { strings.Builder fd int } func (o *TerminalTestOutput) Fd() uintptr { return uintptr(o.fd) } func terminalTestShowEvents(t *testing.T, out io.Writer, events ...Event) { t.Helper() server, client := net.Pipe() done := make(chan error, 1) go func() { done <- terminalShowEvents(client, out) }() encoder := json.NewEncoder(server) for _, event := range events { require.NoError(t, encoder.Encode(event)) } require.NoError(t, server.Close()) require.NoError(t, <-done) require.NoError(t, client.Close()) }