test/owner-chat.test.js
Rama main · réplica de lectura del repositorio original.
// The Billion chat (server/owner.js): what the owner types in the Billion tab
// reaching Billion as [Owner via app], a typed answer to a question, Billion's
// tell_owner and notify_owner as bubbles that follow their question's state,
// Telegram messages in the same thread, the cap, and the token redacted.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { rmSync, writeFileSync, readFileSync, statSync, existsSync, readdirSync } from 'fs';
import { join } from 'path';
import {
ownerSays, chatMessages, chatFilePath, chatPayload, notifyOwner, tellOwner, handleUpdate, dismissWaiting, waitingItems,
APP_PREFIX, OWNER_PREFIX, CHAT_CAP, NOTIFY_WINDOW_MS, MAX_OWNER_CHARS,
} from '../server/owner.js';
import { dropMessages } from '../server/messages.js';
import { sessions, CONFIG_DIR } from '../server/state.js';
const TOKEN = '123456:SECRET-token';
const ENV = { TELEGRAM_BOT_TOKEN: TOKEN, TELEGRAM_CHAT_ID: '42' };
let clock = 3e12;
const now = () => (clock += 10 * NOTIFY_WINDOW_MS);
let b;
// Without the bracketed-paste markers each chunk is wrapped in.
const typed = () => b.pty.write.mock.calls.map(c => c[0]).join("").replace(/\x1b\[20[01]~/g, "");
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ ok: true, result: { message_id: 7 } }) })));
rmSync(join(CONFIG_DIR, 'waiting.json'), { force: true });
rmSync(join(CONFIG_DIR, 'chat.json'), { force: true });
rmSync(join(CONFIG_DIR, 'chat-files'), { recursive: true, force: true });
b = {
id: 'chat-billion', name: 'Billion', isBillion: true, command: 'claude', state: 'WAITING', exited: false,
ownerId: null, stateChangedAt: Date.now() - 5000, recentStrippedLines: [], isTUI: true, lastOutputAt: 0, pty: { write: vi.fn() },
};
sessions.set(b.id, b);
});
afterEach(() => {
vi.unstubAllGlobals();
sessions.delete(b.id);
dropMessages(b.id);
});
describe('the owner typing in the Billion tab', () => {
it('reaches Billion as [Owner via app] and shows as the owner\'s bubble', async () => {
const broadcast = vi.fn();
expect(await ownerSays(' How is the release going? ', { broadcast })).toMatchObject({ ok: true });
expect(typed()).toContain(`${APP_PREFIX} How is the release going?`);
const [m] = chatMessages();
expect(m).toMatchObject({ from: 'owner', via: 'app', text: 'How is the release going?' });
expect(broadcast).toHaveBeenCalledWith({ type: 'chat-message', message: m });
});
it('refuses while Billion is not running, and keeps nothing, so the text stays in the box', async () => {
b.exited = true;
expect(await ownerSays('hello')).toEqual({ error: 'Billion is not running; start it, then send again.' });
expect(chatMessages()).toEqual([]);
});
it('refuses empty text and text past the technical ceiling', async () => {
expect((await ownerSays(' ')).error).toMatch(/empty/);
expect((await ownerSays('x'.repeat(MAX_OWNER_CHARS + 1))).error).toMatch(/send it in parts/);
expect(b.pty.write).not.toHaveBeenCalled();
});
it('delivers a long pasted message whole, newlines kept, and keeps it whole in the thread', async () => {
const listing = Array.from({ length: 60 }, (_, i) => `Item ${i}: a product with a long description`.padEnd(150, '.')).join('\n');
expect(listing.length).toBeGreaterThan(4096);
expect(await ownerSays(listing)).toMatchObject({ ok: true });
// Typed in small bracketed pastes, one after another, then one Enter: one turn.
await vi.waitFor(() => expect(typed()).toBe(`${APP_PREFIX} ${listing}\r`), { timeout: 20000 });
expect(chatMessages()[0].text).toBe(listing);
expect(readFileSync(join(CONFIG_DIR, 'chat.json'), 'utf8')).toContain(JSON.stringify(listing));
});
it('delivers a long answer whole with its line breaks', async () => {
await notifyOwner('Which listing?', { env: {}, now: now(), queue: false });
const [q] = waitingItems();
const listing = Array.from({ length: 60 }, (_, i) => `Line ${i} of the pasted listing`).join('\n');
expect(await ownerSays(listing, { answers: q.id })).toMatchObject({ ok: true });
await vi.waitFor(() => expect(typed()).toBe(`${APP_PREFIX} Q1: ${listing} (re: "Which listing?")\r`), { timeout: 20000 });
expect(chatMessages().at(-1).text).toBe(listing);
});
it('answers the question it names, as a typed answer: the question collapses and the words show as the owner\'s', async () => {
const broadcast = vi.fn();
await notifyOwner('Merge #12?', { env: {}, now: now(), choices: ['yes', 'no'], recommended: 'yes', broadcast, queue: false });
const [q] = waitingItems();
expect(await ownerSays('yes, after CI', { answers: q.id, broadcast })).toMatchObject({ ok: true });
expect(typed()).toContain(`${APP_PREFIX} Q1: yes, after CI (re: "Merge #12?")`);
const [question, answer] = chatMessages();
expect(question.q).toMatchObject({ n: 1, status: 'answered', answer: 'yes, after CI', answeredVia: 'app', choices: ['yes', 'no'], recommended: 'yes' });
expect(answer).toMatchObject({ from: 'owner', via: 'app', text: 'yes, after CI', re: 1 });
});
});
describe('Billion\'s side of the thread', () => {
it('a question is a bubble carrying its choices, and follows it to dismissed', async () => {
const broadcast = vi.fn();
await notifyOwner('Buy the domain?', { env: {}, now: now(), choices: ['yes', 'no'], urgency: 'blocking', broadcast });
const [m] = chatMessages();
expect(m).toMatchObject({ from: 'billion', text: 'Buy the domain?', q: { n: 1, urgency: 'blocking', status: 'open', choices: ['yes', 'no'] } });
dismissWaiting(m.q.id, broadcast);
expect(chatMessages()[0].q.status).toBe('dismissed');
expect(broadcast).toHaveBeenCalledWith({ type: 'chat-message', message: expect.objectContaining({ q: expect.objectContaining({ status: 'dismissed' }) }) });
});
it('redacts the bot token from everything the thread keeps', async () => {
await tellOwner(`the token is ${TOKEN}`, { env: ENV, now: now() });
expect(chatMessages().at(-1).text).toBe('the token is <token>');
expect(JSON.stringify(chatPayload())).not.toContain(TOKEN);
});
it('keeps the newest 500 and lets older ones fall off', async () => {
const old = Array.from({ length: CHAT_CAP }, (_, i) => ({ id: `m${i}`, at: new Date(i).toISOString(), from: 'billion', text: `m${i}` }));
writeFileSync(join(CONFIG_DIR, 'chat.json'), JSON.stringify(old));
await tellOwner('newest', { env: {}, now: now() });
const kept = chatMessages();
expect(kept).toHaveLength(CHAT_CAP);
expect([kept[0].text, kept.at(-1).text]).toEqual(['m1', 'newest']);
});
it('opens a thread that does not exist yet on the questions already asked', async () => {
await notifyOwner('Asked before the chat', { env: {}, now: now(), queue: false });
rmSync(join(CONFIG_DIR, 'chat.json'));
expect(chatMessages()).toEqual([expect.objectContaining({ from: 'billion', text: 'Asked before the chat', q: expect.objectContaining({ n: 1, status: 'open' }) })]);
});
it('keeps an open question past the cap: its bubble is where it is answered', async () => {
const openQ = { id: 'oq', at: new Date(0).toISOString(), from: 'billion', text: 'still open?', q: { id: 'x', n: 1, status: 'open' } };
const old = Array.from({ length: CHAT_CAP - 1 }, (_, i) => ({ id: `m${i}`, at: new Date(i + 1).toISOString(), from: 'billion', text: `m${i}` }));
writeFileSync(join(CONFIG_DIR, 'chat.json'), JSON.stringify([openQ, ...old]));
await tellOwner('newest', { env: {}, now: now() });
const kept = chatMessages();
expect(kept).toHaveLength(CHAT_CAP);
expect(kept.map(m => m.id).slice(0, 2)).toEqual(['oq', 'm1']);
});
it('reads a missing file as an empty thread, and moves a broken one aside rather than overwrite it', async () => {
expect(chatMessages()).toEqual([]);
writeFileSync(join(CONFIG_DIR, 'chat.json'), '{nope');
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(chatMessages()).toEqual([]);
expect(readFileSync(join(CONFIG_DIR, 'chat.json.bad'), 'utf8')).toBe('{nope');
error.mockRestore();
});
it('redacts the token from a question too, in the Waiting list the strip shows', async () => {
await notifyOwner(`use ${TOKEN}?`, { env: ENV, now: now(), queue: false });
expect(waitingItems()[0].text).toBe('use <token>?');
expect(chatMessages()[0].text).toBe('use <token>?');
});
});
describe('Telegram in the same thread', () => {
it('the owner\'s Telegram message shows as theirs, via Telegram', async () => {
const broadcast = vi.fn();
expect(await handleUpdate({ update_id: 1, message: { chat: { id: 42 }, text: 'ship it' } }, { env: ENV, broadcast })).toBe('delivered');
expect(typed()).toContain(`${OWNER_PREFIX} ship it`);
expect(chatMessages()).toEqual([expect.objectContaining({ from: 'owner', via: 'telegram', text: 'ship it' })]);
});
it('a Telegram reply to a question shows as a typed answer; a button tap only collapses it', async () => {
await notifyOwner('Rename the repo?', { env: ENV, now: now(), choices: ['yes', 'no'], telegram: true });
const [q] = waitingItems();
expect(await handleUpdate({ update_id: 2, message: { chat: { id: 42 }, text: 'not yet', reply_to_message: { message_id: q.tgMessageId } } }, { env: ENV })).toBe('answered');
expect(chatMessages().map(m => [m.from, m.text])).toEqual([['billion', 'Rename the repo?'], ['owner', 'not yet']]);
await notifyOwner('Tag v2?', { env: ENV, now: now(), choices: ['yes', 'no'], telegram: true });
const q2 = waitingItems().at(-1);
await handleUpdate({ update_id: 3, callback_query: { id: 'c', data: `${q2.id}:0`, message: { chat: { id: 42 } } } }, { env: ENV });
const thread = chatMessages();
expect(thread).toHaveLength(3);
expect(thread.at(-1).q).toMatchObject({ status: 'answered', answer: 'yes', answeredVia: 'telegram' });
});
});
// Files pasted, dropped or picked in the Billion tab (server/owner.js "The
// chat's attachments").
describe('attachments in the Billion tab', () => {
const file = (name, text = 'hello', type = 'text/plain') => ({ name, type, data: Buffer.from(text).toString('base64') });
const dir = () => join(CONFIG_DIR, 'chat-files');
it('saves them owner-only under chat-files/<message id>, and one turn carries their absolute paths', async () => {
expect(await ownerSays('look at this', { files: [file('shot.png', 'png', 'image/png'), file('notes.pdf')] })).toMatchObject({ ok: true });
const [m] = chatMessages();
expect(m.files).toEqual([{ name: 'shot.png', size: 3, type: 'image/png' }, { name: 'notes.pdf', size: 5, type: 'text/plain' }]);
const a = join(dir(), m.id, 'shot.png');
const b2 = join(dir(), m.id, 'notes.pdf');
await vi.waitFor(() => expect(typed()).toBe(`${APP_PREFIX} look at this (attached: ${a}, ${b2})\r`));
expect(readFileSync(a, 'utf8')).toBe('png');
if (process.platform !== 'win32') {
expect(statSync(a).mode & 0o777).toBe(0o600);
expect(statSync(join(dir(), m.id)).mode & 0o777).toBe(0o700);
}
// In chat.json, so a restart keeps the bubble's files.
expect(JSON.parse(readFileSync(join(CONFIG_DIR, 'chat.json'), 'utf8'))[0].files).toEqual(m.files);
});
it('sends files with no text', async () => {
expect(await ownerSays('', { files: [file('a.txt')] })).toMatchObject({ ok: true });
const [m] = chatMessages();
expect(m.text).toBe('');
await vi.waitFor(() => expect(typed()).toBe(`${APP_PREFIX} (attached: ${join(dir(), m.id, 'a.txt')})\r`));
});
it('answers a question with them, the paths on the same turn', async () => {
await notifyOwner('Which logo?', { env: {}, now: now(), queue: false });
const [q] = waitingItems();
expect(await ownerSays('this one', { answers: q.id, files: [file('logo.svg', '<svg/>', 'image/svg+xml')] })).toMatchObject({ ok: true });
const answer = chatMessages().at(-1);
expect(answer).toMatchObject({ re: 1, text: 'this one', files: [{ name: 'logo.svg', type: 'image/svg+xml' }] });
await vi.waitFor(() => expect(typed()).toBe(`${APP_PREFIX} Q1: this one (re: "Which logo?") (attached: ${join(dir(), answer.id, 'logo.svg')})\r`));
});
it('sanitises names and refuses what the job form refuses: too big, too many, unnamed, clashing', async () => {
expect(await ownerSays('x', { files: [file('../../etc/passwd')] })).toMatchObject({ ok: true });
expect(chatMessages()[0].files[0].name).toBe('.._.._etc_passwd');
const big = { name: 'big.bin', data: Buffer.alloc(10 * 1024 * 1024 + 1).toString('base64') };
expect((await ownerSays('x', { files: [big] })).error).toMatch(/too large/);
expect((await ownerSays('x', { files: Array.from({ length: 21 }, (_, i) => file(`f${i}.txt`)) })).error).toMatch(/At most 20/);
expect((await ownerSays('x', { files: [file('...')] })).error).toMatch(/Unusable/);
expect((await ownerSays('x', { files: [file('A.png'), file('a.png')] })).error).toMatch(/Two files/);
expect((await ownerSays('x', { files: 'nope' })).error).toMatch(/not a list/);
expect(chatMessages()).toHaveLength(1);
});
it('keeps nothing on disk when Billion cannot take the turn', async () => {
b.exited = true;
expect((await ownerSays('x', { files: [file('a.txt')] })).error).toMatch(/not running/);
expect(existsSync(dir()) ? readdirSync(dir()) : []).toEqual([]);
});
it('finds a file only by a message that has it, never outside chat-files', async () => {
await ownerSays('x', { files: [file('a.txt')] });
const [m] = chatMessages();
expect(chatFilePath(m.id, 'a.txt')).toBe(join(dir(), m.id, 'a.txt'));
expect(chatFilePath(m.id, 'b.txt')).toBeNull();
expect(chatFilePath('nope', 'a.txt')).toBeNull();
// A hand-edited chat.json cannot point the route elsewhere.
writeFileSync(join(CONFIG_DIR, 'chat.json'), JSON.stringify([{ id: '..', from: 'owner', text: '', files: [{ name: 'chat.json' }] }]));
expect(chatFilePath('..', 'chat.json')).toBeNull();
});
it('deletes a message\'s files when the cap lets the message go', async () => {
await ownerSays('x', { files: [file('a.txt')] });
const [m] = chatMessages();
const later = Array.from({ length: CHAT_CAP }, (_, i) => ({ id: `m${i}`, at: new Date(i).toISOString(), from: 'billion', text: `m${i}` }));
writeFileSync(join(CONFIG_DIR, 'chat.json'), JSON.stringify([m, ...later]));
await tellOwner('one more', { env: {}, now: now() });
expect(chatMessages().some(x => x.id === m.id)).toBe(false);
expect(existsSync(join(dir(), m.id))).toBe(false);
});
});