package cli import ( "io" "net/http" "net/http/httptest" "slices" "strings" "testing" "time" "unicode/utf8" "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) } }) } } // TestBracketsInMessageWindow checks that each kind of line drawn // from a server event shows nicks, channel names, texts, reasons // and topics holding square brackets as written. func TestBracketsInMessageWindow(t *testing.T) { t.Parallel() // Every event is sent to this channel and has a body of one line. const channel = "#chan[x]" tests := []struct { name string command string from string body string // Whether the line goes to the channel's window rather than // the status window, which is the one shown at start. inChannel bool // How the line ends, after the timestamps. want string }{ { name: "message", command: irc.CmdPrivmsg, inChannel: true, from: "bob[|[]", body: "x[^[] [:::y|z] [red]", want: " x[^[] [:::y|z] [red]", }, { name: "joined", command: irc.CmdJoin, from: "x[^[]", inChannel: true, want: "*** x[^[] has joined #chan[x]", }, { name: "left", command: irc.CmdPart, inChannel: true, from: "erin[away]", body: "[:::y|z]", want: "*** erin[away] has left #chan[x] ([:::y|z])", }, { name: "topic", command: irc.CmdTopic, inChannel: true, from: "dave[afk]", body: "[red]news", want: "*** dave[afk] set topic: [red]news", }, { name: "quit", command: irc.CmdQuit, from: "carol[x]", body: "bye [red]", want: "*** carol[x] has quit (bye [red])", }, { name: "quit without reason", command: irc.CmdQuit, from: "frank[x]", want: "*** frank[x] has quit", }, { name: "nick change", command: irc.CmdNick, from: "erin[m]", body: "erin[x]", want: "*** erin[m] is now known as erin[x]", }, { name: "notice", command: irc.CmdNotice, from: "bob[x]", body: "[::b]note", want: "--bob[x]-- [::b]note", }, { name: "other command", command: irc.CmdMode, body: "+v dave[afk]", want: "[MODE] +v dave[afk]", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() app := &App{ui: NewUI(), nick: testNick} screen := runUI(t, app.ui) if test.inChannel { app.ui.SwitchToBuffer(channel) } app.handleServerMessage(&api.Message{ Command: test.command, From: test.from, To: channel, Body: []string{test.body}, }) got, _ := shownLine(app.ui, screen, 0) if !strings.HasSuffix(got, test.want) { t.Fatalf("message window shows %q, want it to end %q", got, test.want) } }) } } // TestBracketsInCommandErrors checks that when the server refuses // a typed command, the error the client shows holds the server's // answer as written. func TestBracketsInCommandErrors(t *testing.T) { t.Parallel() server := newRefusingServer(t) tests := []struct { typed string // The start of the line, before the error. reply string }{ {typed: testText, reply: "Send error"}, {typed: "/nick bob", reply: "Nick change failed"}, {typed: "/join #x", reply: "Join failed"}, {typed: "/part", reply: "Part failed"}, {typed: "/msg bob hi", reply: "Send failed"}, {typed: "/topic hi", reply: "Topic set failed"}, {typed: "/names", reply: "Names failed"}, {typed: "/list", reply: "List failed"}, {typed: "/motd", reply: "MOTD failed"}, {typed: "/who", reply: "WHO failed"}, {typed: "/whois bob", reply: "WHOIS failed"}, } for _, test := range tests { t.Run(test.reply, func(t *testing.T) { t.Parallel() app := &App{ ui: NewUI(), client: api.NewClient(server.URL), nick: testNick, target: testChannel, connected: true, } screen := runUI(t, app.ui) app.handleInput(test.typed) want := test.reply + ": HTTP error 400: [red]no" got, _ := shownLine(app.ui, screen, 0) if !strings.HasSuffix(got, want) { t.Fatalf("message window shows %q, want it to end %q", got, want) } }) } } // TestBracketsInCommandReplies checks that the other lines the // client draws for a typed command show square brackets as // written, whether they come from the typed line or the server. func TestBracketsInCommandReplies(t *testing.T) { t.Parallel() const channel = "#chan[x]" refusing := newRefusingServer(t) session := newServer(t, `{"id": 1, "nick": "erin[x]"}`) tests := []struct { name string // The server the client is connected to before the line is // typed, if any. server *httptest.Server typed string // Whether the line goes to the channel's window rather than // the status window, and which line of the window it is, // counting from 0. inChannel bool line int want string }{ {name: "unknown command", typed: "/x[red]", want: "Unknown command: /x[red]"}, { name: "nick set", typed: "/nick erin[x]", want: "Nick set to erin[x] (will be used on connect)", }, { name: "nick changed", server: session, typed: "/nick erin[x]", want: "Nick changed to erin[x]", }, { name: "connecting", typed: "/connect " + refusing.URL + "/[x]", want: "Connecting to " + refusing.URL + "/[x]...", }, { name: "connection failed", typed: "/connect " + refusing.URL, line: 1, want: "Connection failed: HTTP error 400: [red]no", }, { name: "connected", typed: "/connect " + session.URL, line: 1, want: "Connected! Nick: erin[x], Session: 1", }, { name: "members", server: newServer(t, `[{"nick": "bob[x]"}]`), typed: "/names", inChannel: true, want: "*** Members of #chan[x]: bob[x]", }, { name: "channel list", typed: "/list", line: 1, want: " #chan[x] [red]news", server: newServer(t, `[{"name": "#chan[x]", "topic": "[red]news"}]`), }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() app := &App{ui: NewUI(), nick: testNick, target: channel} if test.server != nil { app.client = api.NewClient(test.server.URL) app.connected = true } screen := runUI(t, app.ui) // Stop the poll loop a /connect starts, as /quit does. // Cleanups run last first, so this runs before the UI // stops. t.Cleanup(func() { app.mu.Lock() defer app.mu.Unlock() if app.stopPoll != nil { close(app.stopPoll) } }) if test.inChannel { app.ui.SwitchToBuffer(channel) } app.handleInput(test.typed) got, _ := shownLine(app.ui, screen, test.line) if !strings.HasSuffix(got, test.want) { t.Fatalf("message window shows %q, want it to end %q", got, test.want) } }) } } // TestColourTagInMessage checks that a colour tag in a message // shows as written and leaves the message text white, as the // client draws it. func TestColourTagInMessage(t *testing.T) { t.Parallel() const want = " [red]x" app := &App{ui: NewUI(), nick: testNick} screen := runUI(t, app.ui) app.ui.SwitchToBuffer(testChannel) app.handleServerMessage(&api.Message{ Command: irc.CmdPrivmsg, From: testOtherNick, To: testChannel, Body: []string{"[red]x"}, }) got, colour := shownLine(app.ui, screen, 0) if !strings.HasSuffix(got, want) || colour != tcell.ColorWhite { t.Fatalf("message window shows %q, its last character in %v; "+ "want it to end %q, in white", got, colour, want) } } // 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 } // shownLine returns the given line of the message window, counting // from 0, as the screen shows it, without the blanks after it, and // the colour of its last character. Each line in these tests takes // one row of the screen. func shownLine( ui *UI, screen tcell.SimulationScreen, line int, ) (string, tcell.Color) { var ( text string colour tcell.Color ) ui.app.QueueUpdate(func() { cells, width, _ := screen.GetContents() cells = cells[line*width : (line+1)*width] var row strings.Builder for _, cell := range cells { row.WriteString(string(cell.Runes)) } text = strings.TrimRight(row.String(), " ") if text == "" { return } // Each character in these tests takes one cell. last := cells[utf8.RuneCountInString(text)-1] colour, _, _ = last.Style.Decompose() }) return text, colour } // 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 } // newRefusingServer starts a server that answers every request // with the status 400 Bad Request and the body "[red]no" until // the test ends. The client shows that body in its error. func newRefusingServer(t *testing.T) *httptest.Server { t.Helper() server := httptest.NewServer(http.HandlerFunc( func(writer http.ResponseWriter, _ *http.Request) { writer.WriteHeader(http.StatusBadRequest) _, _ = io.WriteString(writer, "[red]no") }, )) 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, and // returns the screen. // //nolint:ireturn // tcell has no exported type for the screen func runUI(t *testing.T, ui *UI) tcell.SimulationScreen { t.Helper() screen := tcell.NewSimulationScreen("") ui.app.SetScreen(screen) // 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") } }) return screen }