Files
thelounge/test/plugins/inputs/msgTest.ts
Max Leiter 8ca7bca70b ircv3: draft/multiline (#5092)
Closes #5052

Multi-line input is currently sent as one PRIVMSG per line, so a paste
arrives as N unrelated messages. On networks that support
[draft/multiline](https://ircv3.net/specs/extensions/multiline) it can
be sent as a single BATCH and stays one message everywhere.

## Summary

Sending goes through `sayMultiline()` in `plugins/inputs/msg.ts` when
the network advertises limits we can respect (`multilineLimits()`); if
the batch is over max-bytes/max-lines the send falls back to individual
messages, and the local echo follows whichever path was actually taken
so the sender sees what everyone else got. `Client.input()` stops
splitting on newlines when the target network has the cap. Inbound
batches arrive from irc-framework already reassembled and are flagged
`multiline` on the `Msg`, which drives line-by-line rendering (`parse()`
joins lines with `<br>`), one entry per message in the text logs, and a
first-non-empty-line notification preview. `MULTILINE_*` standard
replies get readable text.

No pin bump: `master` already points at the irc-framework commit that
carries multiline (kiwiirc/irc-framework#414).

## Test Plan

- New unit tests for the send path (batch, fallback on max-bytes, no
limits advertised, blank lines), the text log format, `parse()`, and the
standard replies.
- Ran the server against a local IRCd advertising `draft/multiline` and
confirmed on the wire: a 3-line message goes out as one `BATCH …
draft/multiline` with three frames; with the cap advertised bare (no
limits) or with `max-bytes` too small it degrades to three plain
PRIVMSGs and the local echo degrades with it; a CRLF paste sends clean
frames and echoes without stray `\r`; an inbound batch from another user
renders as one message.
- Not verified: rendering was checked through unit tests rather than in
a browser.
2026-07-20 14:45:45 -07:00

101 lines
3.4 KiB
TypeScript

import {expect} from "chai";
import msgInput from "../../../server/plugins/inputs/msg";
import Chan from "../../../server/models/chan";
import {ChanType} from "../../../shared/types/chan";
type Sent = {type: "say" | "multiline"; target: string; text: string};
function setup({limits}: {limits: {maxBytes: number; maxLines: number | null} | null}) {
const sent: Sent[] = [];
const echoed: Array<{message: string; multiline?: boolean}> = [];
const chan = new Chan({name: "#thelounge", type: ChanType.CHANNEL});
const irc = {
user: {nick: "alice", username: "alice", host: "localhost"},
network: {
cap: {isEnabled: (cap: string) => cap === "draft/multiline"},
multilineLimits: () => limits,
extractTargetGroup: () => undefined,
},
say(target: string, text: string) {
sent.push({type: "say", target, text});
},
sayMultiline(target: string, lines: string[]) {
if (limits && lines.join("\n").length > limits.maxBytes) {
const error = new Error("too long") as Error & {code: string};
error.code = "MULTILINE_MAX_BYTES";
throw error;
}
sent.push({type: "multiline", target, text: lines.join("\n")});
},
emit(_event: string, data: {message: string; multiline?: boolean}) {
echoed.push(data);
},
};
const network = {
irc,
serverOptions: {supportsReply: false},
getChannel: (name: string) => (name === chan.name ? chan : undefined),
};
const send = (text: string) =>
msgInput.input.call({} as any, network as any, chan, "say", text.split(" "), undefined);
return {send, sent, echoed};
}
describe("msg input", function () {
it("sends a multiline message as a single batch", function () {
const {send, sent, echoed} = setup({limits: {maxBytes: 4096, maxLines: null}});
send("one\ntwo");
expect(sent).to.deep.equal([{type: "multiline", target: "#thelounge", text: "one\ntwo"}]);
expect(echoed).to.have.lengthOf(1);
expect(echoed[0].message).to.equal("one\ntwo");
expect(echoed[0].multiline).to.equal(true);
});
it("sends one message per line when the network advertises no limits", function () {
const {send, sent, echoed} = setup({limits: null});
send("one\ntwo");
expect(sent.map((s) => s.text)).to.deep.equal(["one", "two"]);
expect(echoed.map((e) => e.message)).to.deep.equal(["one", "two"]);
expect(echoed.every((e) => e.multiline === undefined)).to.equal(true);
});
it("falls back to individual messages when the batch is over max-bytes", function () {
const {send, sent, echoed} = setup({limits: {maxBytes: 4, maxLines: null}});
send("one\ntwo");
// The echo has to match what actually went out, not what we attempted
expect(sent.map((s) => s.type)).to.deep.equal(["say", "say"]);
expect(echoed.map((e) => e.message)).to.deep.equal(["one", "two"]);
expect(echoed.every((e) => e.multiline === undefined)).to.equal(true);
});
it("does not send blank lines", function () {
const {send, sent, echoed} = setup({limits: null});
send("one\n\ntwo\n");
expect(sent.map((s) => s.text)).to.deep.equal(["one", "two"]);
expect(echoed.map((e) => e.message)).to.deep.equal(["one", "two"]);
});
it("keeps sending single line messages unbatched", function () {
const {send, sent, echoed} = setup({limits: {maxBytes: 4096, maxLines: null}});
send("hello");
expect(sent).to.deep.equal([{type: "say", target: "#thelounge", text: "hello"}]);
expect(echoed.map((e) => e.message)).to.deep.equal(["hello"]);
});
});