flybrain/services/bridge/tests/onscreen-chat.test.ts
acamilo 660c3cf00d
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
flybrain v0.4.0: public tree (history retained privately)
2026-09-21 15:09:46 +00:00

430 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* The chat-to-screen path (`src/onscreen-chat.ts`), plus the static lint that keeps it the ONLY
* path: no raw chat may reach a sim payload by any other route.
*
* Two kinds of test here:
* - behavioural, against `FakeSimClient` and (for the real HTTP status codes) `tests/fake-sim.ts`;
* - a static-analysis lint over `src/`, in the same spirit as the `send(...)` template lint in
* `tests/templates.test.ts`.
*/
import assert from 'node:assert/strict';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { HttpSimClient } from '../src/sim';
import { FakeClock } from '../src/ratelimit';
import { sanitizeChatText } from '@flybrain/feed';
import {
ECHO_WINDOW_MS,
KNOWN_BOT_LOGINS,
OnscreenChat,
isCommand,
shortenForPanel,
wrapSendWithOnscreenEcho,
type IncomingChatMessage,
} from '../src/onscreen-chat';
import { renderTemplate } from '../src/templates';
import { createSend } from '../src/chat';
import { FakeChatSender, FakeSimClient } from './helpers';
import { startFakeSim, type FakeSimHandle } from './fake-sim';
const SRC_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'src');
function message(overrides: Partial<IncomingChatMessage> = {}): IncomingChatMessage {
return { login: 'mothra_fan', displayName: 'mothra_fan', messageText: 'go left!', ...overrides };
}
function forwarder(
options: { enabled?: boolean; clock?: FakeClock } = {},
): { onscreen: OnscreenChat; sim: FakeSimClient } {
const sim = new FakeSimClient();
const onscreen = new OnscreenChat({
sim,
config: { featureOnscreenChat: options.enabled ?? true, botUser: 'flybridgebot' },
...(options.clock ? { clock: options.clock } : {}),
});
return { onscreen, sim };
}
// -- forwarding viewer messages ----------------------------------------------------------------
void test('an ordinary viewer message is sanitized and posted to /chat', async () => {
const { onscreen, sim } = forwarder();
assert.equal(await onscreen.forwardViewerMessage(message({ messageText: ' go LEFT! ' })), 'sent');
assert.deepEqual(sim.chatCalls, [{ by: 'mothra_fan', text: 'go LEFT!', bot: false }]);
});
void test('commands are not chatter and never reach the sim', async () => {
const { onscreen, sim } = forwarder();
for (const text of ['!sugar', ' !how', '!fly please', '!!!']) {
assert.equal(await onscreen.forwardViewerMessage(message({ messageText: text })), 'command', text);
}
assert.deepEqual(sim.chatCalls, []);
// A bang in the middle of a sentence is not a command.
assert.equal(await onscreen.forwardViewerMessage(message({ messageText: 'go left!' })), 'sent');
});
void test('known third-party bots are dropped', async () => {
const { onscreen, sim } = forwarder();
for (const login of KNOWN_BOT_LOGINS) {
const outcome = await onscreen.forwardViewerMessage(
message({ login: login.toUpperCase(), displayName: login, messageText: 'follow for more' }),
);
assert.equal(outcome, 'bot', login);
}
assert.deepEqual(sim.chatCalls, []);
});
void test('hostile messages are dropped before they leave the process', async () => {
const { onscreen, sim } = forwarder();
const hostile = [
'check out https://evil.example/pwn',
'www.evil.tv',
'discord.gg/abcd',
'zero​width',
'‮gnippot ma I',
'nul',
'h́́́i',
'nice \u{1FAB0}',
'<script>alert(1)</script>',
'`rm -rf /`',
'x'.repeat(5_000),
' ',
'',
];
for (const text of hostile) {
assert.equal(
await onscreen.forwardViewerMessage(message({ messageText: text })),
'rejected',
JSON.stringify(text.slice(0, 40)),
);
}
assert.deepEqual(sim.chatCalls, [], 'not one hostile line reached the sim client');
});
void test('a display name that fails validation is dropped rather than replaced', async () => {
const { onscreen, sim } = forwarder();
// `validateDisplayName` would substitute the literal "a viewer"; for a chat line that would be
// attributing someone's words to a fiction, so the line is dropped instead.
const outcome = await onscreen.forwardViewerMessage(
message({ login: 'weird', displayName: '<b>alex</b>', messageText: 'hello' }),
);
assert.equal(outcome, 'invalid_name');
assert.deepEqual(sim.chatCalls, []);
});
void test('FEATURE_ONSCREEN_CHAT=false forwards nothing at all', async () => {
const { onscreen, sim } = forwarder({ enabled: false });
assert.equal(onscreen.isEnabled, false);
assert.equal(await onscreen.forwardViewerMessage(message()), 'disabled');
assert.equal(await onscreen.forwardBotReply('hello'), 'disabled');
assert.deepEqual(sim.chatCalls, []);
});
void test('a sim that says no is normal; a sim that is not there is not', async () => {
const { onscreen, sim } = forwarder();
for (const [result, expected] of [
[{ ok: false, kind: 'forbidden', error: 'chat is disabled' }, 'sim_refused'],
[{ ok: false, kind: 'rate_limited', retryAfterMs: 1_200 }, 'sim_refused'],
[{ ok: false, kind: 'http_error', status: 422, error: 'chat line refused: url' }, 'sim_refused'],
[{ ok: false, kind: 'timeout' }, 'sim_unreachable'],
[{ ok: false, kind: 'network_error', error: 'ECONNREFUSED' }, 'sim_unreachable'],
] as const) {
sim.chatResult = result as typeof sim.chatResult;
assert.equal(await onscreen.forwardViewerMessage(message()), expected, JSON.stringify(result));
}
});
// -- the bridge's own replies ------------------------------------------------------------------
void test("the bridge's own replies are posted with bot: true", async () => {
const { onscreen, sim } = forwarder();
assert.equal(await onscreen.forwardBotReply('Sugar is on cooldown - try again in 7s.'), 'sent');
assert.deepEqual(sim.chatCalls, [
{ by: 'flybridgebot', text: 'Sugar is on cooldown - try again in 7s.', bot: true },
]);
});
void test('every template renders to something the sanitizer accepts', async () => {
// A template the sanitizer refuses would silently vanish from the CHAT panel, which is exactly
// the kind of quiet mismatch this test exists to catch (em dashes and smart quotes included).
const { onscreen, sim } = forwarder();
const gameTitle = 'Pokemon Red';
const samples: Record<string, unknown> = {
startup: { channel: 'flyplayspokemon', gameTitle },
fly: { gameTitle },
brain: { gameTitle },
how: { gameTitle },
stuck: { label: 'Left the bedroom', durationLabel: '3m 12s' },
sugarAccepted: { by: 'fly_fan_42' },
sugarCooldown: { retryAfterSeconds: 7 },
sugarDisabled: {},
followThanks: { by: 'fly_fan_42' },
raidThanks: { by: 'fly_fan_42', viewers: 12 },
explainerConnectome: {},
explainerButtons: { gameTitle },
explainerReward: { gameTitle },
explainerSugar: {},
explainerHonesty: {},
explainerRepo: {},
};
for (const [id, params] of Object.entries(samples)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const text = renderTemplate(id as any, params as any);
const outcome = await onscreen.forwardBotReply(text);
assert.equal(outcome, 'sent', `template ${id} was refused: ${text}`);
}
assert.equal(sim.chatCalls.length, Object.keys(samples).length);
assert.ok(sim.chatCalls.every((call) => call.bot === true));
});
void test('wrapSendWithOnscreenEcho sends to Twitch first and then to the screen', async () => {
const sender = new FakeChatSender();
const { onscreen, sim } = forwarder();
const send = wrapSendWithOnscreenEcho(createSend(sender), onscreen, renderTemplate);
await send('sugarAccepted', { by: 'fly_fan_42' });
assert.equal(sender.sent.length, 1);
assert.equal(sim.chatCalls.length, 1);
assert.equal(sim.chatCalls[0]?.text, sender.sent[0]);
assert.equal(sim.chatCalls[0]?.bot, true);
});
void test("the EventSub echo of our own reply is dropped, but an unrecognised one is not", async () => {
const clock = new FakeClock(1_000);
const { onscreen, sim } = forwarder({ clock });
await onscreen.forwardBotReply('Sugar is turned off right now.');
assert.equal(sim.chatCalls.length, 1);
// Twitch echoes our own message back over `channel.chat.message`; posting it again would
// double it on screen.
const echo = message({
login: 'flybridgebot',
displayName: 'flybridgebot',
messageText: 'Sugar is turned off right now.',
});
assert.equal(await onscreen.forwardViewerMessage(echo), 'echo');
assert.equal(sim.chatCalls.length, 1, 'nothing posted twice');
// A second, identical message is no longer a recognised echo — the memory is consumed once.
assert.equal(await onscreen.forwardViewerMessage(echo), 'sent');
assert.equal(sim.chatCalls[1]?.bot, true, 'it still goes up marked as the bot');
// And an echo that arrives after the window has passed is treated as an ordinary bot line.
await onscreen.forwardBotReply('Sugar is turned off right now.');
clock.advance(ECHO_WINDOW_MS + 1);
assert.equal(await onscreen.forwardViewerMessage(echo), 'sent');
});
void test('an outcome counter is incremented for every decision', async () => {
const counts = new Map<string, number>();
const sim = new FakeSimClient();
const onscreen = new OnscreenChat({
sim,
config: { featureOnscreenChat: true, botUser: 'flybridgebot' },
metrics: { increment: (name: string, by = 1) => counts.set(name, (counts.get(name) ?? 0) + by) },
});
await onscreen.forwardViewerMessage(message());
await onscreen.forwardViewerMessage(message({ messageText: '!sugar' }));
await onscreen.forwardViewerMessage(message({ messageText: 'www.evil.tv' }));
assert.equal(counts.get('flybridge_onscreen_chat_total{outcome="sent"}'), 1);
assert.equal(counts.get('flybridge_onscreen_chat_total{outcome="command"}'), 1);
assert.equal(counts.get('flybridge_onscreen_chat_total{outcome="rejected"}'), 1);
});
// -- against the real HTTP contract ------------------------------------------------------------
async function withFakeSim(run: (sim: FakeSimHandle) => Promise<void>): Promise<void> {
const fakeSim = await startFakeSim();
try {
await run(fakeSim);
} finally {
await fakeSim.close();
}
}
void test('the sim client speaks the POST /chat contract', async () => {
await withFakeSim(async (fakeSim) => {
const client = new HttpSimClient({ baseUrl: fakeSim.baseUrl, timeoutMs: 1_000 });
fakeSim.queueChatResponse({ kind: 'accept', eventId: 77 });
assert.deepEqual(await client.chat({ by: 'alex', text: 'go left', bot: false }), {
ok: true,
data: { eventId: 77 },
});
assert.deepEqual(fakeSim.requests.at(-1), {
path: '/chat',
body: { by: 'alex', text: 'go left', bot: false },
});
fakeSim.queueChatResponse({ kind: 'refused', reason: 'url' });
assert.deepEqual(await client.chat({ by: 'alex', text: 'bit.ly' }), {
ok: false,
kind: 'http_error',
status: 422,
error: 'chat line refused: url',
});
fakeSim.queueChatResponse({ kind: 'rate_limited', retryAfterMs: 1_500 });
assert.deepEqual(await client.chat({ by: 'alex', text: 'again' }), {
ok: false,
kind: 'rate_limited',
retryAfterMs: 1_500,
});
fakeSim.queueChatResponse({ kind: 'disabled' });
const forbidden = await client.chat({ by: 'alex', text: 'hello' });
assert.equal(forbidden.ok, false);
if (!forbidden.ok) assert.equal(forbidden.kind, 'forbidden');
});
});
void test('a hostile flood against a real fake sim posts only the lines that survive', async () => {
await withFakeSim(async (fakeSim) => {
const client = new HttpSimClient({ baseUrl: fakeSim.baseUrl, timeoutMs: 1_000 });
const onscreen = new OnscreenChat({
sim: client,
config: { featureOnscreenChat: true, botUser: 'flybridgebot' },
});
const flood: IncomingChatMessage[] = [
message({ messageText: 'go left!' }),
message({ messageText: '!sugar' }),
message({ login: 'nightbot', displayName: 'Nightbot', messageText: 'plug plug' }),
message({ messageText: 'join discord.gg/abcd' }),
message({ messageText: '‮evil' }),
message({ messageText: 'the ledge — right there' }),
message({ displayName: 'not a name', messageText: 'hello' }),
];
const outcomes = [];
for (const item of flood) outcomes.push(await onscreen.forwardViewerMessage(item));
assert.deepEqual(outcomes, [
'sent',
'command',
'bot',
'rejected',
'rejected',
'sent',
'invalid_name',
]);
const posted = fakeSim.requests.filter((request) => request.path === '/chat');
assert.equal(posted.length, 2);
assert.deepEqual(
posted.map((request) => (request.body as { text: string }).text),
['go left!', 'the ledge — right there'],
);
});
});
// -- Lint: no raw chat reaches a sim payload by any other route ---------------------------------
function collectSourceFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) files.push(...collectSourceFiles(full));
else if (entry.endsWith('.ts')) files.push(full);
}
return files;
}
/** The one module allowed to sanitize and post chat, and the one allowed to read a message body. */
const CHAT_GATEWAY = 'onscreen-chat.ts';
void test('no raw chat reaches the sim payload: only the sanitized path exists', () => {
const files = collectSourceFiles(SRC_DIR);
assert.ok(files.length > 10, 'expected to find the bridge sources');
const readsMessageBody: string[] = [];
const sanitizes: string[] = [];
const postsChat: string[] = [];
for (const file of files) {
const name = relative(SRC_DIR, file);
const source = readFileSync(file, 'utf8');
// Comments are prose about the rule, not code that breaks it.
const code = source
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
if (/\bmessageText\b/.test(code)) readsMessageBody.push(name);
if (/\bsanitizeChatText\b/.test(code)) sanitizes.push(name);
if (/\.chat\(/.test(code)) postsChat.push(name);
// Nothing, anywhere, may hand a message body to a sim call.
const simCall = /\b(?:sim|simClient|this\.sim)\.(chat|stimulate|reward)\(([^;]*)/g;
let match: RegExpExecArray | null;
while ((match = simCall.exec(code)) !== null) {
assert.ok(
!/messageText|event\.message|\.text\b\s*\+|`/.test(match[2] ?? ''),
`${name}: a sim call is built from a message body or a template literal: ${match[0]}`,
);
}
}
// `commands.ts` reads a body only to parse a command out of its first token; `eventsub.ts`
// hands the body to the gateway. Nothing else may see it.
assert.deepEqual(
readsMessageBody.sort(),
['commands.ts', 'eventsub.ts', CHAT_GATEWAY].sort(),
'a new module started reading chat message bodies',
);
assert.deepEqual(sanitizes, [CHAT_GATEWAY], 'sanitizeChatText must have exactly one call site');
assert.deepEqual(postsChat, [CHAT_GATEWAY], 'POST /chat must have exactly one call site');
});
void test('the gateway posts only text that came out of the sanitizer', () => {
const source = readFileSync(join(SRC_DIR, CHAT_GATEWAY), 'utf8');
// Every `sanitizeChatText(...)` result is bound to a const and checked for null before use.
const bindings = [...source.matchAll(/const (\w+) = sanitizeChatText\(/g)].map((match) => match[1]);
assert.ok(bindings.length >= 2, `expected the viewer and bot paths to sanitize: ${bindings}`);
for (const binding of bindings) {
assert.match(
source,
new RegExp(`if \\(${binding} === null\\) return`),
`${binding} is used without a null check`,
);
}
// The only `text:` ever handed to `sim.chat` is the sanitized local, never a body or a literal.
const payloads = [...source.matchAll(/\.chat\(\{([^}]*)\}\)/g)].map((match) => match[1] ?? '');
assert.ok(payloads.length >= 1, 'expected a sim.chat({...}) call site');
for (const payload of payloads) {
assert.match(payload, /text\b/, payload);
assert.ok(!/messageText/.test(payload), `raw chat in a /chat payload: ${payload}`);
assert.ok(!/[`+]/.test(payload), `a built string in a /chat payload: ${payload}`);
}
});
void test('a long template reply is shortened for the panel, at a word boundary', () => {
const short = 'Sugar is turned off right now.';
assert.equal(shortenForPanel(short), short, 'a line under the cap is untouched');
const long = renderTemplate('brain', { gameTitle: 'Pokemon Red' });
assert.ok([...long].length > 200, 'this template is longer than the cap; that is the case to cover');
const shortened = shortenForPanel(long);
assert.ok([...shortened].length <= 200);
assert.ok(shortened.endsWith('…'));
assert.ok(!shortened.endsWith(' …'), 'no space before the ellipsis');
assert.ok(long.startsWith(shortened.slice(0, -1)), 'it is a prefix of the real reply');
// And what comes out still passes the sanitizer, which is the point of shortening at all.
assert.equal(sanitizeChatText(shortened), shortened);
// A single 300-character word has no boundary to cut at, and is still cut.
const unbroken = 'a'.repeat(300);
assert.equal([...shortenForPanel(unbroken)].length, 200);
});
void test('a viewer line over the cap is refused, never trimmed', async () => {
const { onscreen, sim } = forwarder();
assert.equal(await onscreen.forwardViewerMessage(message({ messageText: 'x'.repeat(201) })), 'rejected');
assert.deepEqual(sim.chatCalls, [], 'shortening is only ever applied to our own replies');
});
void test('isCommand is the same rule the command parser uses', () => {
for (const text of ['!fly', ' !fly', '\t!how', '!']) assert.ok(isCommand(text), text);
for (const text of ['go left!', 'wow!!', '', 'hi ! there']) assert.ok(!isCommand(text), text);
});