package cli import ( "io" "net/http" "net/http/httptest" "slices" "strings" "testing" "time" "github.com/gdamore/tcell/v2" api "sneak.berlin/go/neoirc/internal/cli/api" "sneak.berlin/go/neoirc/pkg/irc" ) // The user's nick, a channel it is in, another user's nick and // a message text, for the tests below. const ( testNick = "erin" testChannel = "#chan" testOtherNick = "bob" testText = "hello" ) // What the status bar shows while the client is connected, and // while it is not. const ( statusConnected = "[connected]" statusDisconnected = "[disconnected]" ) // eventLoopTimeout is how long a test waits for a typed line to // be handled, for the welcome lines, for the UI to stop, for a poll // or for a poll loop to end, before it fails. const eventLoopTimeout = 5 * time.Second // holdTime is how long a test waits to see that something does not // happen: the second line handled while the first is held, or a // poll from a poll loop that was stopped. const holdTime = 200 * time.Millisecond // welcomeLines is the number of lines run adds to the status // window when the client starts. const welcomeLines = 2 // checkInterval is how often TestStartUp reads the status window // while it waits for the welcome lines. const checkInterval = 10 * time.Millisecond func TestMemberPrefix(t *testing.T) { t.Parallel() tests := []struct { name string member api.Member want string }{ { name: "operator", member: api.Member{Nick: "alice", IsOperator: true}, want: "@", }, { name: "voiced member", member: api.Member{Nick: "bob", IsVoiced: true}, want: "+", }, { name: "voiced operator", member: api.Member{ Nick: "Carol", IsOperator: true, IsVoiced: true, }, want: "@", }, { name: "plain member", member: api.Member{Nick: "dave"}, want: "", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() got := memberPrefix(test.member) if got != test.want { t.Fatalf("got %q, want %q", got, test.want) } }) } } // TestPartEvent checks that a PART event adds a line to the // channel's buffer and marks it unread, except that the // user's own part does not mark it unread. func TestPartEvent(t *testing.T) { t.Parallel() tests := []struct { name string from string wantUnread int }{ {name: "own part", from: testNick, wantUnread: 0}, {name: "other member", from: testOtherNick, wantUnread: 1}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() app := &App{ui: NewUI(), nick: testNick} runUI(t, app.ui) app.handleServerMessage(&api.Message{ Command: irc.CmdPart, From: test.from, To: testChannel, }) lines, unread := bufferState(app.ui, testChannel) if lines != 1 || unread != test.wantUnread { t.Fatalf("got %d lines and %d unread, want 1 and %d", lines, unread, test.wantUnread) } }) } } // TestOwnCommandShownOnce checks that a message, join or // part made from neoirc-cli shows as one line in the // channel's buffer once the server's event for it arrives, // and does not mark the channel unread. func TestOwnCommandShownOnce(t *testing.T) { t.Parallel() tests := []struct { name string send func(app *App) event api.Message }{ { name: "message", send: func(app *App) { app.handleInput(testText) }, event: api.Message{ Command: irc.CmdPrivmsg, From: testNick, To: testChannel, Body: []string{testText}, }, }, { name: "join", send: func(app *App) { app.cmdJoin(testChannel) }, event: api.Message{ Command: irc.CmdJoin, From: testNick, To: testChannel, }, }, { name: "part", send: func(app *App) { app.cmdPart(testChannel) }, event: api.Message{ Command: irc.CmdPart, From: testNick, To: testChannel, }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() // The server accepts every command. Only the response // to a JOIN is read, and it says the session joined. server := newServer(t, `{"status": "joined"}`) app := &App{ ui: NewUI(), client: api.NewClient(server.URL), nick: testNick, target: testChannel, connected: true, } runUI(t, app.ui) app.ui.SwitchToBuffer(testChannel) test.send(app) app.handleServerMessage(&test.event) lines, unread := bufferState(app.ui, testChannel) if lines != 1 || unread != 0 { t.Fatalf("got %d lines and %d unread, want 1 and 0", lines, unread) } }) } } // TestJoinResponse checks that /join switches to the channel's // buffer and makes it the target only when the server answers // that the session joined. func TestJoinResponse(t *testing.T) { t.Parallel() tests := []struct { name string response string // The buffer shown and the target after the join. want string }{ { name: "joined", response: `{"status": "joined", "channel": "#chan"}`, want: testChannel, }, { name: "refused", response: `{"status": "error"}`, want: testOtherNick, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() app := &App{ ui: NewUI(), client: api.NewClient(newServer(t, test.response).URL), nick: testNick, target: testOtherNick, connected: true, } runUI(t, app.ui) app.ui.SwitchToBuffer(testOtherNick) app.cmdJoin(testChannel) var shown string app.ui.app.QueueUpdate(func() { shown = app.ui.buffers[app.ui.currentBuffer].Name }) if shown != test.want || app.target != test.want { t.Fatalf("got buffer %s and target %s, want %s", shown, app.target, test.want) } }) } } // TestAltDigit checks that Alt and a digit shows that window and // makes its channel or user the target, as /window does. Window // 0, the status window, leaves no target. func TestAltDigit(t *testing.T) { t.Parallel() tests := []struct { name string digit rune // The buffer shown and the target after the key. wantBuffer string wantTarget string }{ { name: "channel window", digit: '1', wantBuffer: testChannel, wantTarget: testChannel, }, { name: "status window", digit: '0', wantBuffer: "(status)", wantTarget: "", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() app := &App{ui: NewUI(), nick: testNick, target: testOtherNick} handled := handledLines(app) runUI(t, app.ui) // Window 1 is the channel. Window 2, the one shown, is // the other user's. app.ui.SwitchToBuffer(testChannel) app.ui.SwitchToBuffer(testOtherNick) key := tcell.NewEventKey(tcell.KeyRune, test.digit, tcell.ModAlt) app.ui.app.QueueEvent(key) waitHandled(t, handled, "/window "+string(test.digit)) var shown string app.ui.app.QueueUpdate(func() { shown = app.ui.buffers[app.ui.currentBuffer].Name }) if shown != test.wantBuffer || app.target != test.wantTarget { t.Fatalf("got buffer %s and target %q, want %s and %q", shown, app.target, test.wantBuffer, test.wantTarget) } }) } } // TestWindowConnectionState checks that typing /window 0, or // pressing Alt and 0, leaves the status bar showing [connected] // only while the client is connected, and [disconnected] otherwise. func TestWindowConnectionState(t *testing.T) { t.Parallel() const line = "/window 0" typeLine := func(ui *UI) { for _, r := range line { ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) } ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) } pressAltZero := func(ui *UI) { ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyRune, '0', tcell.ModAlt)) } tests := []struct { name string send func(ui *UI) connected bool want string }{ { name: "typed line, not connected", send: typeLine, connected: false, want: "[disconnected]", }, { name: "Alt and 0, not connected", send: pressAltZero, connected: false, want: "[disconnected]", }, { name: "typed line, connected", send: typeLine, connected: true, want: "[connected]", }, { name: "Alt and 0, connected", send: pressAltZero, connected: true, want: "[connected]", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() app := &App{ui: NewUI(), nick: testNick, connected: test.connected} handled := handledLines(app) runUI(t, app.ui) test.send(app.ui) waitHandled(t, handled, line) status := statusBarText(app.ui) if !strings.Contains(status, test.want) { t.Fatalf("status bar is %q, want it to show %s", status, test.want) } }) } } // TestQueryConnectionState checks that typing /query bob leaves the // status bar showing [connected] only while the client is connected, // and [disconnected] otherwise. func TestQueryConnectionState(t *testing.T) { t.Parallel() const line = "/query " + testOtherNick tests := []struct { name string connected bool want string }{ {name: "not connected", connected: false, want: statusDisconnected}, {name: "connected", connected: true, want: statusConnected}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() app := &App{ui: NewUI(), nick: testNick, connected: test.connected} handled := handledLines(app) runUI(t, app.ui) for _, r := range line { app.ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) } app.ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) waitHandled(t, handled, line) status := statusBarText(app.ui) if !strings.Contains(status, test.want) { t.Fatalf("status bar is %q, want it to show %s", status, test.want) } }) } } // TestEventLoopKeepsRunning sends Alt and 1, then a typed line and // Enter, to a running UI. It checks that both are handled, so the // event loop did not stop on either. func TestEventLoopKeepsRunning(t *testing.T) { t.Parallel() app := &App{ui: NewUI(), nick: testNick} handled := handledLines(app) runUI(t, app.ui) // Window 1 is the channel, and window 0, the status window, is // shown. The typed line adds a line to the status window, as // the client is not connected. app.ui.SwitchToBuffer(testChannel) app.ui.SwitchBuffer(0) app.ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyRune, '1', tcell.ModAlt)) for _, r := range testText { app.ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) } app.ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) waitHandled(t, handled, "/window 1") waitHandled(t, handled, testText) } // TestLinesKeepOrder holds the handling of a first line until a // second line has been passed in. It fails if the second line is // handled while the first is still held. func TestLinesKeepOrder(t *testing.T) { t.Parallel() const firstLine, secondLine = "one", "two" ui := NewUI() handled := make(chan string) release := make(chan struct{}) ui.OnInput(func(text string) { if text == firstLine { <-release } handled <- text }) ui.callOnInput(firstLine) ui.callOnInput(secondLine) select { case got := <-handled: t.Fatalf("handled %q while the first line was held", got) case <-time.After(holdTime): } close(release) waitHandled(t, handled, firstLine) waitHandled(t, handled, secondLine) } // TestSecondConnectStopsFirstPollLoop connects to one server, then // to a second that holds every poll. Only the second connection's // poll loop may poll the second server. func TestSecondConnectStopsFirstPollLoop(t *testing.T) { t.Parallel() const session = `{"id": 1, "nick": "erin"}` // The first server answers every request at once, so the first // poll loop polls it until the second /connect. first := newServer(t, session) second, polled := newHoldingServer(t, session) app := &App{ui: NewUI(), nick: testNick} runUI(t, app.ui) app.cmdConnect(first.URL) app.cmdConnect(second.URL) waitPoll(t, polled, "the second server") // The second connection's poll loop waits on its held poll, so // another poll comes from the first loop. select { case <-polled: t.Fatal("the first poll loop polled again after the second /connect") case <-time.After(holdTime): } // Stop the second poll loop as /quit does. /quit itself also // stops the UI, and runUI's cleanup stopping it again is a data // race inside tview. app.mu.Lock() close(app.stopPoll) app.mu.Unlock() } // TestSecondConnectIgnoresHeldPoll holds a poll of the earlier // connection at its server while a second /connect runs, then // answers it with a cursor and a message. It fails if that answer // moves the cursor or shows the message. func TestSecondConnectIgnoresHeldPoll(t *testing.T) { t.Parallel() const session = `{"id": 1, "nick": "erin"}` const answer = `{"messages": [{"command": "PRIVMSG", "from": "bob", "to": "#chan", "body": ["hello"]}], "last_id": 42}` // The first server sends on polled when the poll arrives, and // answers it once release is closed. polled := make(chan struct{}) release := make(chan struct{}) first := httptest.NewServer(http.HandlerFunc( func(writer http.ResponseWriter, request *http.Request) { select { case polled <- struct{}{}: case <-request.Context().Done(): return } select { case <-release: _, _ = io.WriteString(writer, answer) case <-request.Context().Done(): } }, )) t.Cleanup(first.Close) t.Cleanup(first.CloseClientConnections) // The second server answers every request at once with the // session. A poll answered with it has no cursor and no messages, // so the second poll loop never moves the cursor. second := newServer(t, session) // The test runs the earlier connection's poll loop itself, so // that it can wait for the loop to end. stop := make(chan struct{}) app := &App{ ui: NewUI(), client: api.NewClient(first.URL), nick: testNick, connected: true, stopPoll: stop, } runUI(t, app.ui) done := make(chan struct{}) go func() { app.pollLoop(stop) close(done) }() select { case <-polled: case <-time.After(eventLoopTimeout): t.Fatalf("the first server got no poll within %v", eventLoopTimeout) } app.cmdConnect(second.URL) close(release) select { case <-done: case <-time.After(eventLoopTimeout): t.Fatalf("the earlier poll loop did not end within %v", eventLoopTimeout) } app.mu.Lock() cursor := app.lastQID app.mu.Unlock() if cursor != 0 { t.Errorf("the earlier connection's answer moved the cursor to %d", cursor) } lines, _ := bufferState(app.ui, testChannel) if lines != 0 { t.Errorf("the earlier connection's message was shown in %s", testChannel) } // Stop the second poll loop as /quit does, without stopping the UI. app.mu.Lock() close(app.stopPoll) app.mu.Unlock() } // TestSecondConnectAfterFailedPoll holds a poll of the earlier // connection at its server while a second /connect runs, then // fails it. A poll loop waits pollRetry after a failed poll, then // polls again. The test fails if the earlier poll loop polls the // second server. func TestSecondConnectAfterFailedPoll(t *testing.T) { t.Parallel() const session = `{"id": 1, "nick": "erin"}` first, firstPolled := newHoldingServer(t, session) // The second server holds the one poll of the second // connection's poll loop, so any other poll comes from the // earlier loop. second, secondPolled := newHoldingServer(t, session) // The test runs the earlier connection's poll loop itself, so // that it can wait for the loop to end. stop := make(chan struct{}) app := &App{ ui: NewUI(), client: api.NewClient(first.URL), nick: testNick, connected: true, stopPoll: stop, } runUI(t, app.ui) done := make(chan struct{}) go func() { app.pollLoop(stop) close(done) }() waitPoll(t, firstPolled, "the first server") app.cmdConnect(second.URL) waitPoll(t, secondPolled, "the second server") // Closing the held poll's connection makes that poll fail. first.CloseClientConnections() select { case <-done: case <-secondPolled: t.Fatal("the earlier poll loop polled the second server") case <-time.After(eventLoopTimeout): t.Fatalf("the earlier poll loop did not end within %v", eventLoopTimeout) } // Stop the second poll loop as /quit does, without stopping the UI. app.mu.Lock() close(app.stopPoll) app.mu.Unlock() } // TestStartUp runs the client's start-up path on a simulation // screen. It fails unless the event loop starts and the status // window gets two lines: first the one that holds "Welcome to // neoirc-cli", then the /connect hint. The status bar must show // [disconnected]. func TestStartUp(t *testing.T) { t.Parallel() ui := NewUI() ui.app.SetScreen(tcell.NewSimulationScreen("")) // One slot, so that run's goroutine can end even after the // cleanup below has stopped waiting for it. done := make(chan error, 1) go func() { done <- run(ui) }() t.Cleanup(func() { ui.Stop() select { case err := <-done: if err != nil { t.Errorf("run: %v", err) } case <-time.After(eventLoopTimeout): t.Error("run did not return after the UI was stopped") } }) // run adds the welcome lines on a goroutine, so the status // window is read until it holds two lines. Each read waits for // the event loop, so the reads run on a goroutine too, which // this test stops waiting for after eventLoopTimeout. The // channel has one slot, so that goroutine can still end. shown := make(chan []string, 1) go func() { for { var lines []string ui.app.QueueUpdate(func() { lines = slices.Clone(ui.getOrCreateBuffer("(status)").Lines) }) if len(lines) == welcomeLines { shown <- lines return } time.Sleep(checkInterval) } }() var lines []string select { case lines = <-shown: case <-time.After(eventLoopTimeout): t.Fatalf("the status window did not get the welcome lines within %v", eventLoopTimeout) } if !strings.Contains(lines[0], "Welcome to neoirc-cli") { t.Errorf("first status line is %q, want the welcome line", lines[0]) } if !strings.Contains(lines[1], "/connect ") { t.Errorf("second status line is %q, want the /connect hint", lines[1]) } // run sets the status bar before it adds the welcome lines. status := statusBarText(ui) if !strings.Contains(status, "[disconnected]") { t.Errorf("status bar is %q, want it to show [disconnected]", status) } } // TestStatusBar checks that the status bar shows [connected], and // shows a nick, target or window name with square brackets as it // is. func TestStatusBar(t *testing.T) { t.Parallel() tests := []struct { name string nick string target string unread string want string }{ { name: "plain names", nick: testNick, target: testChannel, unread: testOtherNick, want: " [connected] erin [1:#chan] #chan [Act: 2:bob(1)]", }, { name: "nicks with brackets", nick: "erin[away]", target: "bob[m]", unread: "dave[afk]", want: " [connected] erin[away] [1:bob[m]] bob[m] [Act: 2:dave[afk](1)]", }, { name: "nicks with brackets and symbols", nick: "bob[|[]", target: "x[^[]", unread: testOtherNick, want: " [connected] bob[|[] [1:x[^[]] x[^[] [Act: 2:bob(1)]", }, { // /query takes any text as the target. name: "query target with brackets", nick: testNick, target: "[:::y|z]", unread: testOtherNick, want: " [connected] erin [1:[:::y|z]] [:::y|z] [Act: 2:bob(1)]", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() ui := NewUI() runUI(t, ui) // Window 1, the one shown, is the target's. Window 2 // gets one unread line. ui.SwitchToBuffer(test.target) ui.AddLine(test.unread, testText) ui.SetStatus(test.nick, test.target, "connected") got := statusBarText(ui) if got != test.want { t.Fatalf("status bar is %q, want %q", got, test.want) } }) } } // TestUnreadLine checks that a line added to a window that is not // shown puts that window in the status bar's unread list at once, // and that the status bar keeps the nick, target and connection // state last set. func TestUnreadLine(t *testing.T) { t.Parallel() ui := NewUI() runUI(t, ui) // Window 1, the one shown, is the channel's. The line goes to // window 2 after SetStatus, so only AddLine can show it in the // status bar. ui.SwitchToBuffer(testChannel) ui.SetStatus(testNick, testChannel, "connected") ui.AddLine(testOtherNick, testText) const want = " [connected] erin [1:#chan] #chan [Act: 2:bob(1)]" got := statusBarText(ui) if got != want { t.Fatalf("status bar is %q, want %q", got, want) } } // TestOwnEventFromOtherClient checks that a message, notice, // join or part that the user's session makes from another // client shows as one line in the buffer it belongs to. func TestOwnEventFromOtherClient(t *testing.T) { t.Parallel() tests := []struct { name string event api.Message buffer string }{ { name: "channel message", event: api.Message{ Command: irc.CmdPrivmsg, From: testNick, To: testChannel, Body: []string{testText}, }, buffer: testChannel, }, { name: "direct message", event: api.Message{ Command: irc.CmdPrivmsg, From: testNick, To: testOtherNick, Body: []string{testText}, }, buffer: testOtherNick, }, { name: "notice", event: api.Message{ Command: irc.CmdNotice, From: testNick, To: testChannel, Body: []string{testText}, }, buffer: "(status)", }, { name: "join", event: api.Message{ Command: irc.CmdJoin, From: testNick, To: testChannel, }, buffer: testChannel, }, { name: "part", event: api.Message{ Command: irc.CmdPart, From: testNick, To: testChannel, }, buffer: testChannel, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() app := &App{ui: NewUI(), nick: testNick} runUI(t, app.ui) app.handleServerMessage(&test.event) lines, _ := bufferState(app.ui, test.buffer) if lines != 1 { t.Fatalf("got %d lines in %s, want 1", lines, test.buffer) } }) } } // bufferState returns the number of lines in the named // buffer and how many of them are unread. func bufferState(ui *UI, name string) (int, int) { var lines, unread int ui.app.QueueUpdate(func() { buf := ui.getOrCreateBuffer(name) lines = len(buf.Lines) unread = buf.Unread }) return lines, unread } // statusBarText returns the text the status bar shows. // GetText(true) leaves out whatever tview reads as a tag, as // drawing does. func statusBarText(ui *UI) string { var text string ui.app.QueueUpdate(func() { text = ui.statusBar.GetText(true) }) return text } // handledLines makes app.ui pass each typed line to // app.handleInput, as Run does, and returns a channel that // receives each line once it has been handled. func handledLines(app *App) <-chan string { handled := make(chan string) app.ui.OnInput(func(text string) { app.handleInput(text) handled <- text }) return handled } // waitHandled fails the test unless want is the next line handled, // within eventLoopTimeout. func waitHandled(t *testing.T, handled <-chan string, want string) { t.Helper() select { case got := <-handled: if got != want { t.Fatalf("handled %q, want %q", got, want) } case <-time.After(eventLoopTimeout): t.Fatalf("%q was not handled within %v", want, eventLoopTimeout) } } // newServer starts a server that answers every request with // body until the test ends. func newServer(t *testing.T, body string) *httptest.Server { t.Helper() server := httptest.NewServer(http.HandlerFunc( func(writer http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(writer, body) }, )) t.Cleanup(server.Close) return server } // newHoldingServer starts a server that sends on the returned // channel for each poll and holds the poll until its connection // closes. It answers any other request with body. func newHoldingServer( t *testing.T, body string, ) (*httptest.Server, <-chan struct{}) { t.Helper() polled := make(chan struct{}) server := httptest.NewServer(http.HandlerFunc( func(writer http.ResponseWriter, request *http.Request) { if request.Method != http.MethodGet || request.URL.Path != "/api/v1/messages" { _, _ = io.WriteString(writer, body) return } select { case polled <- struct{}{}: case <-request.Context().Done(): } <-request.Context().Done() }, )) // Close waits for the held polls, which end only once their // connections close. Cleanups run last first, so the connections // close before Close runs. t.Cleanup(server.Close) t.Cleanup(server.CloseClientConnections) return server, polled } // waitPoll fails the test unless server gets a poll, sent on // polled, within eventLoopTimeout. func waitPoll(t *testing.T, polled <-chan struct{}, server string) { t.Helper() select { case <-polled: case <-time.After(eventLoopTimeout): t.Fatalf("%s got no poll within %v", server, eventLoopTimeout) } } // runUI runs ui on a simulated screen until the test ends. func runUI(t *testing.T, ui *UI) { t.Helper() ui.app.SetScreen(tcell.NewSimulationScreen("")) // One slot, so that Run's goroutine can end even after the // cleanup below has stopped waiting for it. done := make(chan error, 1) go func() { done <- ui.Run() }() t.Cleanup(func() { ui.Stop() select { case err := <-done: if err != nil { t.Errorf("run ui: %v", err) } case <-time.After(eventLoopTimeout): t.Error("the UI did not stop: its event loop stopped responding") } }) }