// @vitest-environment node /** * @fileoverview Discord bridge: pure rules, the 0600 store, the REST client, the gateway state * machine (fake socket) and the bridge's gates. No network, no real Discord. */ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { DiscordConfig } from '../src/types/discord.js'; import { buildPost, DISCORD_STARTUP_HINT, ensureReceivedDir, isDiscordCdnUrl, safeAttachmentName, screenTail, splitForDiscord, tablesToCodeBlocks, DEFAULT_DISCORD_CONFIG, DiscordApi, DiscordBridge, DiscordGateway, discordConfigPath, discordConfigProblem, maskToken, readDiscordConfig, readThreadMap, sanitiseReply, shouldPing, threadNameFor, writeDiscordConfig, type DiscordFetch, type DiscordMessage, type GatewaySocket, } from '../src/discord-bridge.js'; const TOKEN = 'MTA1.secret.token-value-1234567890'; const GUILD = '111111111111111111'; const PARENT = '222222222222222222'; const ME = '333333333333333333'; const THREAD = '444444444444444444'; const CFG: DiscordConfig = { ...DEFAULT_DISCORD_CONFIG, enabled: true, token: TOKEN, guildId: GUILD, parentChannelId: PARENT, allowedUserIds: [ME], }; const MSG: DiscordMessage = { event: 'hook:permission_prompt', title: 'Permission Required', body: '[w1-app] Tool: Bash', urgency: 'critical', sessionId: 's1', sessionName: 'w1-app', }; let dir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'discord-bridge-')); }); afterEach(() => rmSync(dir, { recursive: true, force: true })); describe('pure rules', () => { it('names the command in the hint Claude is launched with', () => { expect(DISCORD_STARTUP_HINT).toContain('codeman notify'); }); it('sanitiseReply makes one printable line plus Enter', () => { expect(sanitiseReply('yes')).toBe('yes\r'); expect(sanitiseReply('line one\nline two\r\nthree')).toBe('line one line two three\r'); expect(sanitiseReply('a\u001b[31mb\u0000c')).toBe('a[31mbc\r'); expect(sanitiseReply(' \n\t ')).toBeNull(); expect(sanitiseReply('x'.repeat(5000))!.length).toBe(1001); }); it('threadNameFor falls back to the id and clips to 100 characters', () => { expect(threadNameFor('w3-app', 'abcdef123456')).toBe('w3-app'); expect(threadNameFor('', 'abcdef123456')).toBe('abcdef12'); expect(threadNameFor('n'.repeat(300), 'x')).toHaveLength(100); }); it('shouldPing follows pingMode', () => { expect(shouldPing('off', 'critical', true)).toBe(false); expect(shouldPing('critical', 'critical')).toBe(true); expect(shouldPing('critical', 'warning')).toBe(false); expect(shouldPing('critical', 'warning', true)).toBe(true); expect(shouldPing('all', 'info')).toBe(true); }); it('buildPost pings only allowlisted ids and never parses mentions', () => { const post = buildPost({ ...MSG, body: '@everyone <@999999999999999999>' }, CFG); expect(post.content).toContain(`<@${ME}>`); expect(post.allowed_mentions).toEqual({ parse: [], users: [ME] }); const quiet = buildPost(MSG, { ...CFG, pingMode: 'off' }); expect(quiet.content).not.toContain('<@'); expect(quiet.allowed_mentions.users).toEqual([]); }); it('buildPost fences the output tail and stays under the Discord limit', () => { const post = buildPost({ ...MSG, tail: 'Press the reset button ``` now\n' + 'x'.repeat(5000) }, CFG); expect(post.content.length).toBeLessThanOrEqual(1900); expect(post.content).toContain('Press the reset button'); expect(post.content).not.toMatch(/button ``` now/); }); it('discordConfigProblem checks ids and demands a complete config to enable', () => { expect(discordConfigProblem(CFG)).toBeNull(); expect(discordConfigProblem(DEFAULT_DISCORD_CONFIG)).toBeNull(); expect(discordConfigProblem({ ...CFG, guildId: 'abc' })).toMatch(/Guild ID/); expect(discordConfigProblem({ ...CFG, allowedUserIds: ['x'] })).toMatch(/user ID/); expect(discordConfigProblem({ ...CFG, allowedUserIds: [] })).toMatch(/at least one/i); expect(discordConfigProblem({ ...CFG, token: '' })).toMatch(/token/); expect(discordConfigProblem({ ...CFG, token: 'has space' })).toMatch(/token/); }); it('maskToken never reveals the middle', () => { expect(maskToken(TOKEN)).not.toContain('secret'); expect(maskToken('')).toBe(''); }); }); describe('store', () => { it('round-trips the config, writes it 0600 and coerces junk to defaults', async () => { await writeDiscordConfig(dir, CFG); expect(statSync(discordConfigPath(dir)).mode & 0o777).toBe(0o600); expect(await readDiscordConfig(dir)).toEqual(CFG); expect(await readDiscordConfig(join(dir, 'missing'))).toEqual(DEFAULT_DISCORD_CONFIG); }); }); function jsonRes(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); } describe('DiscordApi', () => { it('sends the token only in Authorization and retries once on 429', async () => { const calls: Array<{ url: string; init: RequestInit }> = []; const fetchImpl: DiscordFetch = async (url, init) => { calls.push({ url: String(url), init }); return calls.length === 1 ? jsonRes({ retry_after: 0.01 }, 429) : jsonRes({ id: THREAD }); }; const api = new DiscordApi( () => TOKEN, fetchImpl, async () => undefined ); const out = await api.createThread(PARENT, 'w1-app'); expect(out).toEqual({ id: THREAD }); expect(calls).toHaveLength(2); expect((calls[0].init.headers as Record).Authorization).toBe(`Bot ${TOKEN}`); expect(String(calls[0].init.body)).not.toContain(TOKEN); }); it('turns an HTTP failure into an error that does not contain the token', async () => { const api = new DiscordApi( () => TOKEN, async () => jsonRes({ code: 50001 }, 403) ); const err = await api.post(PARENT, { content: 'x', allowed_mentions: { parse: [], users: [] } }).catch((e) => e); expect(err.status).toBe(403); expect(String(err.message)).not.toContain(TOKEN); }); }); class FakeSocket implements GatewaySocket { sent: Array<{ op: number; d?: Record | number | null }> = []; closed: number[] = []; private h: { message?: (d: { toString(): string }) => void; close?: (c: number) => void } = {}; send(data: string): void { this.sent.push(JSON.parse(data)); } close(code = 1000): void { this.closed.push(code); this.h.close?.(code); } on(event: string, cb: (...a: never[]) => void): void { (this.h as Record)[event] = cb; } recv(frame: unknown): void { this.h.message?.({ toString: () => JSON.stringify(frame) }); } drop(code: number): void { this.h.close?.(code); } } describe('DiscordGateway', () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); function setup() { const sockets: FakeSocket[] = []; const urls: string[] = []; const seen: unknown[] = []; const gw = new DiscordGateway( () => TOKEN, (m) => seen.push(m), (url) => { urls.push(url); const s = new FakeSocket(); sockets.push(s); return s; }, () => 10 ); return { gw, sockets, urls, seen }; } it('identifies with the three intents, heartbeats, and surfaces MESSAGE_CREATE', () => { const { gw, sockets, seen } = setup(); gw.start(); sockets[0].recv({ op: 10, d: { heartbeat_interval: 1000 } }); expect(sockets[0].sent[0].op).toBe(2); expect((sockets[0].sent[0].d as { intents: number }).intents).toBe(1 | 512 | 32768); sockets[0].recv({ op: 0, s: 1, t: 'READY', d: { session_id: 'sess', resume_gateway_url: 'wss://resume.example' } }); expect(gw.state).toBe('connected'); vi.advanceTimersByTime(1000); expect(sockets[0].sent.at(-1)).toEqual({ op: 1, d: 1 }); sockets[0].recv({ op: 0, s: 2, t: 'MESSAGE_CREATE', d: { id: 'm', channel_id: THREAD, content: 'hi', author: { id: ME } }, }); expect(seen).toHaveLength(1); gw.stop(); }); it('resumes on the resume url after a drop', () => { const { gw, sockets, urls } = setup(); gw.start(); sockets[0].recv({ op: 10, d: { heartbeat_interval: 1000 } }); sockets[0].recv({ op: 0, s: 5, t: 'READY', d: { session_id: 'sess', resume_gateway_url: 'wss://resume.example' } }); sockets[0].drop(1006); vi.advanceTimersByTime(10); expect(urls[1]).toContain('wss://resume.example'); sockets[1].recv({ op: 10, d: { heartbeat_interval: 1000 } }); expect(sockets[1].sent[0]).toMatchObject({ op: 6, d: { session_id: 'sess', seq: 5 } }); gw.stop(); }); it('ignores a frame that arrives after stop(): no heartbeat timer, nothing sent', () => { const { gw, sockets } = setup(); gw.start(); gw.stop(); sockets[0].recv({ op: 10, d: { heartbeat_interval: 1000 } }); expect(vi.getTimerCount()).toBe(0); expect(sockets[0].sent).toHaveLength(0); }); it('stops for good on a close code that retrying cannot fix', () => { const { gw, sockets } = setup(); gw.start(); sockets[0].drop(4014); vi.advanceTimersByTime(60_000); expect(sockets).toHaveLength(1); expect(gw.state).toBe('error'); expect(gw.lastError).toMatch(/Message Content Intent/); expect(gw.lastError).not.toContain(TOKEN); }); it('closes a connection that stopped acknowledging heartbeats', () => { const { gw, sockets } = setup(); gw.start(); sockets[0].recv({ op: 10, d: { heartbeat_interval: 1000 } }); vi.advanceTimersByTime(1000); // beat 1, no ack vi.advanceTimersByTime(1000); // beat 2 sees no ack expect(sockets[0].closed).toContain(4000); gw.stop(); }); }); describe('DiscordBridge', () => { let posts: Array<{ path: string; method: string; body: Record | undefined }>; let sendInput: ReturnType Promise>>; let t: number; function bridge(overrides: Partial = {}, fetchOverride?: DiscordFetch) { const fetchImpl: DiscordFetch = fetchOverride ?? (async (url, init) => { const path = new URL(String(url)).pathname.replace('/api/v10', ''); posts.push({ path, method: String(init.method), body: init.body ? JSON.parse(String(init.body)) : undefined }); if (path.endsWith('/threads')) return jsonRes({ id: THREAD }); return init.method === 'PUT' ? new Response(null, { status: 204 }) : jsonRes({ id: 'msg' }); }); return new DiscordBridge({ configDir: dir, fetchImpl, sendInput, socketFactory: () => new FakeSocket(), now: () => t, sleep: async () => undefined, }); } beforeEach(async () => { posts = []; t = 1_000_000; sendInput = vi.fn(async () => true); await writeDiscordConfig(dir, CFG); }); it('creates the thread lazily on the first alert, reuses it, and persists the map', async () => { const b = bridge(); await b.notify(MSG); t += 10_000; await b.notify({ ...MSG, event: 'hook:idle_prompt', urgency: 'warning' }); expect(posts.filter((p) => p.path.endsWith('/threads'))).toHaveLength(1); expect(posts.filter((p) => p.path === `/channels/${THREAD}/messages`)).toHaveLength(2); expect(await readThreadMap(dir)).toEqual({ s1: THREAD }); b.stop(); }); it('makes one thread when two alerts race', async () => { const b = bridge(); await Promise.all([b.notify(MSG), b.notify({ ...MSG, event: 'hook:elicitation_dialog' })]); expect(posts.filter((p) => p.path.endsWith('/threads'))).toHaveLength(1); b.stop(); }); it('dedupes an automatic event within 3 s and says so, which is not the "bridge is off" answer', async () => { const b = bridge(); expect((await b.notify(MSG))?.ok).toBe(true); const second = await b.notify(MSG); expect(second).toMatchObject({ ok: true, deduped: true }); expect(posts.filter((p) => p.path.endsWith('/messages'))).toHaveLength(1); b.stop(); }); it('never dedupes a deliberate post: two different messages from one tab within 3 s both go out', async () => { const b = bridge(); const a = await b.notify({ ...MSG, event: 'session:discord-notify', body: 'first' }, { deliberate: true }); const c = await b.notify({ ...MSG, event: 'session:discord-notify', body: 'second' }, { deliberate: true }); expect(a?.deduped).toBeUndefined(); expect(c?.deduped).toBeUndefined(); expect(posts.filter((p) => p.path.endsWith('/messages'))).toHaveLength(2); b.stop(); }); it('builds the alert tail only for a post that goes out (not while off, not when deduped)', async () => { const tail = vi.fn(() => 'screen text'); const b = bridge(); await b.notify({ ...MSG, tail }); expect(tail).toHaveBeenCalledTimes(1); await b.notify({ ...MSG, tail }); // deduped expect(tail).toHaveBeenCalledTimes(1); expect(posts.at(-1)?.body?.content).toContain('screen text'); await writeDiscordConfig(dir, { ...CFG, enabled: false }); await b.reload(); await b.notify({ ...MSG, event: 'hook:idle_prompt', tail }); // off expect(tail).toHaveBeenCalledTimes(1); b.stop(); }); it('survives a tail that throws', async () => { const b = bridge(); const r = await b.notify({ ...MSG, tail: () => { throw new Error('tmux gone'); }, }); expect(r?.ok).toBe(true); b.stop(); }); it('writes the thread map one write at a time, so threads made at once all survive a restart', async () => { let n = 0; const b = bridge({}, async (url, init) => { const path = new URL(String(url)).pathname.replace('/api/v10', ''); if (path.endsWith('/threads')) return jsonRes({ id: String(500000000000000000n + BigInt(++n)) }); return jsonRes({ id: 'msg' }); }); await Promise.all(['a1', 'a2', 'a3', 'a4'].map((id) => b.notify({ ...MSG, sessionId: id, event: `e-${id}` }))); const map = await readThreadMap(dir); expect(Object.keys(map).sort()).toEqual(['a1', 'a2', 'a3', 'a4']); b.stop(); }); it('skips info events under scope attention and everything while disabled', async () => { const b = bridge(); expect(await b.notify({ ...MSG, urgency: 'info' })).toBeNull(); await writeDiscordConfig(dir, { ...CFG, enabled: false }); await b.reload(); expect(await b.notify(MSG)).toBeNull(); expect(posts).toHaveLength(0); b.stop(); }); it('recreates a thread that was deleted by hand', async () => { let first = true; const b = bridge({}, async (url, init) => { const path = new URL(String(url)).pathname.replace('/api/v10', ''); posts.push({ path, method: String(init.method), body: undefined }); if (path.endsWith('/threads')) return jsonRes({ id: THREAD }); if (first && path.endsWith('/messages')) { first = false; return jsonRes({ code: 10003 }, 404); } return jsonRes({ id: 'msg' }); }); const r = await b.notify(MSG); expect(r?.ok).toBe(true); expect(posts.filter((p) => p.path.endsWith('/threads'))).toHaveLength(2); b.stop(); }); it('reports a failure without the token and never throws', async () => { const b = bridge({}, async () => { throw new TypeError('fetch failed'); }); const r = await b.notify(MSG); expect(r).toMatchObject({ ok: false, error: 'Network error' }); expect(JSON.stringify(r)).not.toContain(TOKEN); b.stop(); }); describe('replies', () => { const reply = (over: Record = {}) => ({ id: 'm1', channel_id: THREAD, guild_id: GUILD, content: 'press y', author: { id: ME }, ...over, }); async function ready() { const b = bridge(); await b.notify(MSG); posts.length = 0; t += 10_000; return b; } it('types an allowed reply into the mapped session and ticks it', async () => { const b = await ready(); await b.onGatewayMessage(reply()); expect(sendInput).toHaveBeenCalledWith('s1', 'press y\r'); expect(posts[0].method).toBe('PUT'); expect(posts[0].path).toContain('/reactions/'); b.stop(); }); it.each([ ['a stranger', { author: { id: '999999999999999999' } }], ['a bot', { author: { id: ME, bot: true } }], ['another guild', { guild_id: '555555555555555555' }], ['an unmapped channel', { channel_id: '666666666666666666' }], ['an empty message', { content: ' ' }], ])('drops %s without typing anything', async (_n, over) => { const b = await ready(); await b.onGatewayMessage(reply(over)); expect(sendInput).not.toHaveBeenCalled(); expect(posts).toHaveLength(0); b.stop(); }); it('turns a multi-line reply into one line and rate limits a burst', async () => { const b = await ready(); await b.onGatewayMessage(reply({ content: 'a\nb' })); await b.onGatewayMessage(reply({ content: 'again' })); expect(sendInput).toHaveBeenCalledTimes(1); expect(sendInput).toHaveBeenCalledWith('s1', 'a b\r'); // The second reply was not typed, and the person is told so instead of it vanishing. expect(posts.some((p) => p.method === 'PUT' && p.path.includes(encodeURIComponent('⏳')))).toBe(true); b.stop(); }); it('says so in the thread when the tab is gone', async () => { sendInput.mockResolvedValueOnce(false); const b = await ready(); await b.onGatewayMessage(reply()); expect(posts[0].method).toBe('POST'); expect(String(posts[0].body?.content)).toMatch(/gone/); b.stop(); }); }); it('starts afresh when the parent channel changes: tabs get a thread in the new channel', async () => { const b = bridge(); await b.notify(MSG); expect(await readThreadMap(dir)).toEqual({ s1: THREAD }); posts.length = 0; await writeDiscordConfig(dir, { ...CFG, parentChannelId: '777777777777777777' }); await b.reload(); await b.notify({ ...MSG, event: 'hook:idle_prompt' }); expect(posts.find((p) => p.path.endsWith('/threads'))?.path).toBe('/channels/777777777777777777/threads'); b.stop(); }); it('keeps the threads across a restart under the same parent channel', async () => { const first = bridge(); await first.notify(MSG); first.stop(); posts.length = 0; const second = bridge(); await second.reload(); await second.notify({ ...MSG, event: 'hook:idle_prompt' }); expect(posts.some((p) => p.path.endsWith('/threads'))).toBe(false); second.stop(); }); it('a failed thread creation reports its own failure even if another post succeeds meanwhile', async () => { let n = 0; const b = bridge({}, async (url) => { if (String(url).endsWith('/threads')) { await new Promise((r) => setTimeout(r, 20)); return new Response('{}', { status: 403 }); } n++; return jsonRes({ id: 'msg' }); }); const [failed, ok] = await Promise.all([ b.notify(MSG), b.notify({ ...MSG, sessionId: undefined, event: 'session:error' }), ]); expect(n).toBeGreaterThan(0); expect(ok?.ok).toBe(true); expect(failed).toMatchObject({ ok: false, status: 403 }); b.stop(); }); it('drops a thread whose creation finished after the parent channel changed', async () => { let release!: () => void; const gate = new Promise((r) => (release = r)); const b = bridge({}, async (url, init) => { if (String(url).endsWith(`/channels/${PARENT}/threads`)) { await gate; return jsonRes({ id: THREAD }); } posts.push({ path: new URL(String(url)).pathname.replace('/api/v10', ''), method: String(init.method), body: init.body ? JSON.parse(String(init.body)) : undefined, }); return jsonRes({ id: 'msg' }); }); const first = b.notify(MSG); await new Promise((r) => setTimeout(r, 10)); await writeDiscordConfig(dir, { ...CFG, parentChannelId: '777777777777777777' }); await b.reload(); release(); expect(await first).toMatchObject({ ok: false }); // The thread made in the old channel is tidied, not left open there. expect(posts).toContainEqual({ path: `/channels/${THREAD}`, method: 'PATCH', body: { archived: true } }); expect(await readThreadMap(dir)).toEqual({}); b.stop(); }); describe('full last message', () => { const LONG = Array.from({ length: 60 }, (_, i) => `**Point ${i}**: ${'detail '.repeat(10)}\nIs this right?`).join( '\n\n' ); it('posts the whole message as markdown: head and first part together, the rest without a ping', async () => { const b = bridge(); await b.notify({ ...MSG, tail: 'screen chrome', fullText: async () => ({ text: LONG }) }); const sent = posts.filter((p) => p.path.endsWith('/messages')); expect(sent.length).toBeGreaterThan(1); const first = String(sent[0].body?.content); expect(first).toContain(MSG.title); expect(first).toContain('**Point 0**'); expect(first).not.toContain('screen chrome'); expect(first).not.toContain('```'); // markdown, not a code box expect(sent[1].body?.allowed_mentions).toEqual({ parse: [], users: [] }); const all = sent.map((p) => String(p.body?.content)).join('\n'); expect(all.match(/Is this right\?/g)).toHaveLength(60); b.stop(); }); it('posts a Markdown table as an aligned code block, since Discord does not render tables', async () => { const b = bridge(); const table = [ '| Variant | Decode (tokens/s) |', '|---|---:|', '| kv-f16 | 557.0 |', '| n-gram mod | 550.2 |', ].join('\n'); await b.notify({ ...MSG, fullText: async () => ({ text: `Results:\n\n${table}` }) }); const posted = posts .filter((p) => p.path.endsWith('/messages')) .map((p) => String(p.body?.content)) .join('\n'); expect(posted).toContain('```\nVariant Decode (tokens/s)\n'); expect(posted).not.toContain('|---'); b.stop(); }); it('does not post the same message twice (idle, then stop), and falls back to the tail without a transcript', async () => { const b = bridge(); await b.notify({ ...MSG, fullText: async () => ({ text: 'Short answer.' }) }); t += 10_000; posts.length = 0; await b.notify({ ...MSG, event: 'hook:stop', tail: 'chrome', fullText: async () => ({ text: 'Short answer.' }) }); const again = posts.filter((p) => p.path.endsWith('/messages')).map((p) => String(p.body?.content)); expect(again).toHaveLength(1); expect(again[0]).not.toContain('Short answer.'); expect(again[0]).not.toContain('chrome'); t += 10_000; posts.length = 0; await b.notify({ ...MSG, event: 'hook:idle_prompt', tail: 'the screen', fullText: async () => undefined }); expect(String(posts.find((p) => p.path.endsWith('/messages'))?.body?.content)).toContain('the screen'); b.stop(); }); it('leaves a message to be posted again when its first post failed', async () => { let fail = true; const b = bridge({}, async (url, init) => { const path = new URL(String(url)).pathname.replace('/api/v10', ''); posts.push({ path, method: String(init.method), body: init.body ? JSON.parse(String(init.body)) : undefined }); if (path.endsWith('/threads')) return jsonRes({ id: THREAD }); if (fail && path.endsWith('/messages')) return new Response('{}', { status: 500 }); return jsonRes({ id: 'msg' }); }); const first = await b.notify({ ...MSG, event: 'hook:stop', fullText: async () => ({ text: 'The answer.' }) }); expect(first).toMatchObject({ ok: false }); fail = false; t += 10_000; posts.length = 0; await b.notify({ ...MSG, event: 'hook:idle_prompt', fullText: async () => ({ text: 'The answer.' }) }); expect(posts.map((p) => String(p.body?.content)).join('\n')).toContain('The answer.'); b.stop(); }); it('converts only what can be posted, so one very wide table cell cannot stall the server', async () => { const b = bridge(); const wide = ['| a |', '|---|', `| ${'x'.repeat(1024 * 1024)} |`, ...Array(190).fill('| y |')].join('\n'); const t0 = Date.now(); await b.notify({ ...MSG, event: 'hook:idle_prompt', fullText: async () => ({ text: wide, version: 'turn-1' }) }); expect(Date.now() - t0).toBeLessThan(500); const said = posts.filter((p) => p.path.endsWith('/messages')).map((p) => String(p.body?.content)); expect(said.length).toBeLessThanOrEqual(9); // the title post plus at most 8 parts expect(said.at(-1)).toContain('continues in the tab'); b.stop(); }); it('does not post the same message twice when two alerts for one tab overlap', async () => { let release!: () => void; const gate = new Promise((r) => (release = r)); const b = bridge({}, async (url, init) => { const path = new URL(String(url)).pathname.replace('/api/v10', ''); posts.push({ path, method: String(init.method), body: init.body ? JSON.parse(String(init.body)) : undefined }); if (path.endsWith('/threads')) return jsonRes({ id: THREAD }); if (path.endsWith('/messages')) await gate; return jsonRes({ id: 'msg' }); }); const full = async () => ({ text: 'The one answer.', version: 'turn-1' }); const first = b.notify({ ...MSG, event: 'hook:idle_prompt', fullText: full }); await vi.waitFor(() => expect(posts.some((p) => p.path.endsWith('/messages'))).toBe(true)); const second = b.notify({ ...MSG, event: 'hook:stop', fullText: full }); await new Promise((r) => setTimeout(r, 20)); release(); await Promise.all([first, second]); const said = posts.filter((p) => p.path.endsWith('/messages')).map((p) => String(p.body?.content)); expect(said.filter((c) => c.includes('The one answer.'))).toHaveLength(1); b.stop(); }); it('posts a later turn that ends on the same words, but not the same turn twice', async () => { const b = bridge(); await b.notify({ ...MSG, fullText: async () => ({ text: 'Done.', version: 'turn-1' }) }); t += 10_000; posts.length = 0; await b.notify({ ...MSG, event: 'hook:stop', fullText: async () => ({ text: 'Done.', version: 'turn-1' }) }); expect(posts.map((p) => String(p.body?.content)).join('\n')).not.toContain('Done.'); t += 10_000; posts.length = 0; await b.notify({ ...MSG, fullText: async () => ({ text: 'Done.', version: 'turn-2' }) }); expect(posts.map((p) => String(p.body?.content)).join('\n')).toContain('Done.'); b.stop(); }); it('falls back to the tail when reading the transcript throws or hangs', async () => { const b = bridge(); await b.notify({ ...MSG, tail: 'the screen', fullText: async () => { throw new Error('no transcript'); }, }); expect(String(posts.find((p) => p.path.endsWith('/messages'))?.body?.content)).toContain('the screen'); b.stop(); }); }); describe('sweepStaleThreads', () => { const ME = '555555555555555555'; const OTHER = '666666666666666666'; const OLD = (n: number) => `30000000000000000${n}`; // a snowflake from 2015, long before `now` let young: string; let calls: Array<{ method: string; path: string; body?: unknown }>; async function swept(staleThreads: 'keep' | 'archive' | 'delete') { await writeDiscordConfig(dir, { ...CFG, staleThreads }); t = Date.now(); young = String((BigInt(t - 60_000 - 1420070400000) << 22n) + 5n); calls = []; const th = (id: string, owner = ME, parent = PARENT) => ({ id, owner_id: owner, parent_id: parent }); const b = bridge({}, async (url, init) => { const path = new URL(String(url)).pathname.replace('/api/v10', ''); const method = String(init.method); calls.push({ method, path, body: init.body ? JSON.parse(String(init.body)) : undefined }); if (path === '/users/@me') return jsonRes({ id: ME }); if (path.endsWith('/threads/active')) return jsonRes({ threads: [ th(THREAD), // mapped th(OLD(1)), // stale th(OLD(2), OTHER), // someone else's thread th(OLD(3), ME, '999999999999999999'), // another channel th(young), // too new to judge ], }); if (path.includes('/threads/archived')) return jsonRes({ threads: [th(OLD(4))] }); if (path.endsWith('/threads')) return jsonRes({ id: THREAD }); return method === 'PUT' ? new Response(null, { status: 204 }) : jsonRes({ id: 'msg' }); }); return b.notify(MSG).then(() => { calls.length = 0; return b; }); } const touched = () => calls.filter((c) => c.method !== 'GET').map((c) => `${c.method} ${c.path.split('/').pop()}`); it('does nothing while set to keep', async () => { const b = await swept('keep'); expect(await b.sweepStaleThreads()).toBe(0); expect(calls).toHaveLength(0); b.stop(); }); it("archives only this bot's old, unmapped threads in the parent channel, and skips archived ones", async () => { const b = await swept('archive'); expect(await b.sweepStaleThreads()).toBe(1); expect(touched()).toEqual([`PATCH ${OLD(1)}`]); expect(calls.some((c) => c.path.includes('/threads/archived'))).toBe(false); b.stop(); }); it('deletes stale active and archived threads, and still spares the rest', async () => { const b = await swept('delete'); expect(await b.sweepStaleThreads()).toBe(2); expect(touched()).toEqual([`DELETE ${OLD(1)}`, `DELETE ${OLD(4)}`]); b.stop(); }); it('does nothing when the map is empty, since a lost map would make every thread look stale', async () => { const b = await swept('delete'); await b.sessionDeleted('s1'); // empties the map calls.length = 0; expect(await b.sweepStaleThreads()).toBe(0); expect(calls).toHaveLength(0); b.stop(); }); }); it('keeps the gateway off while held (boot) and starts it on release', async () => { const b = bridge(); b.hold(); await b.reload(); expect(b.gatewayState).toBe('off'); await b.release(); expect(b.gatewayState).not.toBe('off'); b.stop(); }); it('prune drops thread entries for tabs that no longer exist, and keeps the live ones', async () => { const b = bridge(); await b.notify(MSG); await b.notify({ ...MSG, sessionId: 's2', sessionName: 'w2' }); await b.prune(['s2']); expect(Object.keys(await readThreadMap(dir))).toEqual(['s2']); await b.prune([]); // nothing restored: leave the map alone expect(Object.keys(await readThreadMap(dir))).toEqual(['s2']); b.stop(); }); describe('sendTest', () => { it('posts with the config it is given and leaves the live config alone', async () => { const auth: string[] = []; const b = bridge({}, async (_url, init) => { auth.push(String((init.headers as Record).Authorization)); return jsonRes({ id: 'msg' }); }); await b.reload(); expect((await b.sendTest({ ...CFG, token: 'test-token' }))?.ok).toBe(true); // A save that landed during the test must not be undone: the live token is still the saved one. await b.notify(MSG); expect(auth[0]).toBe('Bot test-token'); expect(auth.slice(1).every((a) => a === `Bot ${CFG.token}`)).toBe(true); b.stop(); }); }); it('names a thread that Discord answers without an id, instead of reporting an old result', async () => { const b = bridge({}, async (url) => (String(url).endsWith('/threads') ? jsonRes({}) : jsonRes({ id: 'msg' }))); const r = await b.notify(MSG); expect(r).toMatchObject({ ok: false, error: 'Discord did not return a thread' }); b.stop(); }); it('replaces a gateway that gave up on a fatal close when the same config is saved again', async () => { const sockets: FakeSocket[] = []; const b = new DiscordBridge({ configDir: dir, fetchImpl: async () => jsonRes({ id: 'msg' }), sendInput, socketFactory: () => { const s = new FakeSocket(); sockets.push(s); return s; }, now: () => t, sleep: async () => undefined, }); await b.reload(); await vi.waitFor(() => expect(sockets).toHaveLength(1)); sockets[0].drop(4014); // Message Content Intent off: fatal, no reconnect expect(b.gatewayState).toBe('error'); await b.reload(); await vi.waitFor(() => expect(sockets).toHaveLength(2)); expect(b.gatewayState).not.toBe('error'); b.stop(); }); it('keeps the gateway session across a save that changes neither the switch nor the token', async () => { const b = bridge(); await b.reload(); const first = (b as unknown as { gateway: unknown }).gateway; expect(first).not.toBeNull(); await b.reload(); expect((b as unknown as { gateway: unknown }).gateway).toBe(first); await writeDiscordConfig(dir, { ...CFG, token: 'another-token' }); await b.reload(); expect((b as unknown as { gateway: unknown }).gateway).not.toBe(first); b.stop(); }); it('does not rename a thread to the name it already has on first sight', async () => { const b = bridge(); await b.notify(MSG); posts.length = 0; await b.sessionRenamed('s1', 'w1-app'); expect(posts).toHaveLength(0); b.stop(); }); describe('per-tab state', () => { const internals = (b: DiscordBridge) => b as unknown as { names: Map; lastReply: Map }; it('a deleted tab that never had a thread still frees its entries', async () => { const b = bridge(); await b.reload(); await b.sessionCreated('nothread', 'w'); await b.sessionRenamed('nothread', 'w'); internals(b).lastReply.set('nothread', 1); await b.sessionDeleted('nothread'); expect(internals(b).names.has('nothread')).toBe(false); expect(internals(b).lastReply.has('nothread')).toBe(false); b.stop(); }); it('stop() clears the maps and the gateway', async () => { const b = bridge(); await b.reload(); await b.sessionCreated('x', 'w'); await b.sessionRenamed('x', 'w'); b.stop(); expect(internals(b).names.size).toBe(0); expect(b.gatewayState).toBe('off'); }); }); describe('tab lifecycle', () => { it('creates the thread at once under onSessionStart, not under lazy', async () => { const lazy = bridge(); await lazy.sessionCreated('s1', 'w1-app'); expect(posts).toHaveLength(0); lazy.stop(); await writeDiscordConfig(dir, { ...CFG, threadCreation: 'onSessionStart' }); const eager = bridge(); await eager.reload(); await eager.sessionCreated('s1', 'w1-app'); expect(posts.filter((p) => p.path.endsWith('/threads'))).toHaveLength(1); eager.stop(); }); it.each([ ['archive', 'PATCH', { archived: true }], ['delete', 'DELETE', undefined], ] as const)('onSessionDelete %s calls the matching API', async (mode, method, body) => { await writeDiscordConfig(dir, { ...CFG, onSessionDelete: mode }); const b = bridge(); await b.notify(MSG); posts.length = 0; await b.sessionDeleted('s1'); expect(posts).toEqual([{ path: `/channels/${THREAD}`, method, body }]); expect(await readThreadMap(dir)).toEqual({}); b.stop(); }); it('keep leaves the thread alone but still forgets the mapping', async () => { await writeDiscordConfig(dir, { ...CFG, onSessionDelete: 'keep' }); const b = bridge(); await b.notify(MSG); posts.length = 0; await b.sessionDeleted('s1'); expect(posts).toHaveLength(0); expect(await readThreadMap(dir)).toEqual({}); b.stop(); }); it('forgetTab drops the per-tab state but keeps the thread and its map entry', async () => { await writeDiscordConfig(dir, { ...CFG, onSessionDelete: 'delete' }); const b = bridge(); await b.notify(MSG); posts.length = 0; b.forgetTab('s1'); await b.sessionRenamed('s1', 'other'); // first sight again: recorded, no PATCH expect(posts).toHaveLength(0); expect(await readThreadMap(dir)).toEqual({ s1: THREAD }); b.stop(); }); it('a tab closed while its thread is being created has that thread closed and no entry kept', async () => { await writeDiscordConfig(dir, { ...CFG, onSessionDelete: 'archive' }); let release!: () => void; const gate = new Promise((r) => (release = r)); const b = bridge({}, async (url, init) => { const path = new URL(String(url)).pathname.replace('/api/v10', ''); posts.push({ path, method: String(init.method), body: init.body ? JSON.parse(String(init.body)) : undefined }); if (path.endsWith('/threads')) { await gate; return jsonRes({ id: THREAD }); } return jsonRes({ id: 'msg' }); }); const first = b.notify(MSG); await vi.waitFor(() => expect(posts.some((p) => p.path.endsWith('/threads'))).toBe(true)); await b.sessionDeleted('s1'); release(); const out = await first; expect(posts).toContainEqual({ path: `/channels/${THREAD}`, method: 'PATCH', body: { archived: true } }); expect(out).toMatchObject({ ok: false, error: 'The tab was closed' }); expect(await readThreadMap(dir)).toEqual({}); b.stop(); }); it('renames the thread only when the name really changed', async () => { const b = bridge(); await b.notify(MSG); await b.sessionRenamed('s1', 'w1-app'); // same name notify() used: first sight records it posts.length = 0; await b.sessionRenamed('s1', 'w1-app'); await b.sessionRenamed('s1', 'fix login'); expect(posts).toEqual([{ path: `/channels/${THREAD}`, method: 'PATCH', body: { name: 'fix login' } }]); b.stop(); }); }); }); describe('files', () => { it('safeAttachmentName keeps one safe path segment', () => { expect(safeAttachmentName('report final (1).pdf')).toBe('report_final_1_.pdf'); expect(safeAttachmentName('../../etc/passwd')).toBe('passwd'); expect(safeAttachmentName('.env')).toBe('env'); expect(safeAttachmentName('')).toBe('file'); expect(safeAttachmentName('x'.repeat(300))).toHaveLength(80); }); it('isDiscordCdnUrl accepts only Discord https hosts', () => { expect(isDiscordCdnUrl('https://cdn.discordapp.com/attachments/1/2/a.png')).toBe(true); expect(isDiscordCdnUrl('https://media.discordapp.net/attachments/1/2/a.png')).toBe(true); expect(isDiscordCdnUrl('http://cdn.discordapp.com/a.png')).toBe(false); expect(isDiscordCdnUrl('https://evil.example/a.png')).toBe(false); expect(isDiscordCdnUrl('https://discordapp.com.evil.example/a.png')).toBe(false); expect(isDiscordCdnUrl('http://169.254.169.254/')).toBe(false); expect(isDiscordCdnUrl('nonsense')).toBe(false); }); it('sanitiseReply appends attached paths after the cap, and a file alone is enough', () => { expect(sanitiseReply('see this', ['/w/.codeman-discord/1-a.png'])).toBe( 'see this [attached: /w/.codeman-discord/1-a.png]\r' ); expect(sanitiseReply('', ['/w/a'])).toBe('[attached: /w/a]\r'); expect(sanitiseReply('x'.repeat(5000), ['/w/a'])!.endsWith('[attached: /w/a]\r')).toBe(true); }); it('postWithFile sends multipart with a safe filename and no JSON content type', async () => { let seen: { init: RequestInit } | null = null; const api = new DiscordApi( () => TOKEN, async (_u, init) => { seen = { init }; return jsonRes({ id: 'm' }); } ); await api.postWithFile( PARENT, { content: 'hi', allowed_mentions: { parse: [], users: [] } }, { name: '../x y.log', data: new Uint8Array([1, 2, 3]) } ); const body = seen!.init.body as FormData; expect(body).toBeInstanceOf(FormData); expect(JSON.parse(String(body.get('payload_json'))).content).toBe('hi'); expect((body.get('files[0]') as File).name).toBe('x_y.log'); expect((seen!.init.headers as Record)['Content-Type']).toBeUndefined(); }); }); describe('DiscordBridge files and startup hint', () => { const reply = (over: Record = {}) => ({ id: '555555555555555555', channel_id: THREAD, guild_id: GUILD, content: '', author: { id: ME }, attachments: [ { id: 'a1', filename: 'shot one.png', size: 3, url: 'https://cdn.discordapp.com/attachments/1/2/shot.png' }, ], ...over, }); let work: string; let sent: Array<[string, string]>; let posted: Array<{ path: string; body?: Record }>; let downloads: string[]; function make( cfg: Partial = {}, dirFor: (id: string) => string | undefined = () => work, awaitingChoice?: (id: string) => boolean, agentExited?: (id: string) => boolean ) { const b = new DiscordBridge({ configDir: dir, fetchImpl: async (url, init) => { const u = new URL(String(url)); if (u.hostname === 'cdn.discordapp.com') { downloads.push(String(url)); return new Response(new Uint8Array([7, 8, 9])); } const path = u.pathname.replace('/api/v10', ''); posted.push({ path, body: init.body ? JSON.parse(String(init.body)) : undefined }); return path.endsWith('/threads') ? jsonRes({ id: THREAD }) : jsonRes({ id: 'msg' }); }, sendInput: async (id, text) => (sent.push([id, text]), true), sessionDir: dirFor, awaitingChoice, agentExited, socketFactory: () => new FakeSocket(), now: () => t, sleep: async () => undefined, }); return writeDiscordConfig(dir, { ...CFG, ...cfg }).then(async () => { await b.reload(); await b.notify(MSG); posted.length = 0; return b; }); } let t = 5_000_000; beforeEach(() => { work = mkdtempSync(join(tmpdir(), 'discord-work-')); sent = []; posted = []; downloads = []; }); afterEach(() => rmSync(work, { recursive: true, force: true })); it('saves an attachment under .codeman-discord and types its path, even with no text', async () => { const b = await make(); await b.onGatewayMessage(reply()); const dest = join(work, '.codeman-discord', '555555555555555555-shot_one.png'); expect(readFileSync(dest)).toEqual(Buffer.from([7, 8, 9])); expect(statSync(dest).mode & 0o777).toBe(0o600); expect(sent).toEqual([['s1', `[attached: ${dest}]\r`]]); b.stop(); }); it('refuses a non-Discord URL, an oversize file and a sixth file, and says so in the thread', async () => { const b = await make(); const many = Array.from({ length: 6 }, (_v, i) => ({ id: String(i), filename: `f${i}.txt`, size: 1, url: 'https://cdn.discordapp.com/a/' + i, })); await b.onGatewayMessage( reply({ content: 'look', attachments: [ { id: 'x', filename: 'evil.png', size: 1, url: 'http://169.254.169.254/latest' }, { id: 'y', filename: 'huge.bin', size: 9 * 1024 * 1024, url: 'https://cdn.discordapp.com/a/huge' }, ...many, ], }) ); expect(downloads.some((u) => u.includes('169.254'))).toBe(false); expect(downloads.some((u) => u.includes('huge'))).toBe(false); expect(downloads).toHaveLength(3); // 5 allowed in total, two of which were the refused ones above expect(posted.map((p) => String(p.body?.content)).find((c) => c.startsWith('Not saved'))).toMatch( /evil\.png.*huge\.bin/ ); b.stop(); }); it('says there is no folder for a remote tab and types nothing for a file-only reply', async () => { const b = await make({}, () => undefined); await b.onGatewayMessage(reply()); expect(sent).toHaveLength(0); expect(String(posted.at(-1)?.body?.content)).toMatch(/no local folder/); b.stop(); }); it('says the agent has exited and types nothing when the tab reports it', async () => { const b = await make({}, undefined, undefined, () => true); await b.onGatewayMessage(reply({ content: 'hello', attachments: [] })); expect(sent).toHaveLength(0); expect(String(posted.at(-1)?.body?.content)).toMatch(/exited/); expect(posted.some((p) => p.path.includes('reactions'))).toBe(false); b.stop(); }); it('types a reply that waits on a download before a later text reply', async () => { const b = await make(); const slow = b.onGatewayMessage(reply({ content: 'first' })); await new Promise((r) => setTimeout(r, 0)); // the first reply is under way (gate passed) before the clock moves t += 5_000; const fast = b.onGatewayMessage(reply({ id: '555555555555555556', content: 'second', attachments: [] })); await Promise.all([slow, fast]); expect(sent.map((s) => s[1].split(' ')[0])).toEqual(['first', 'second\r']); b.stop(); }); it('types a lone digit without Enter while a dialog is waiting, and with Enter otherwise', async () => { let waiting = true; const b = await make({}, undefined, () => waiting); await b.onGatewayMessage(reply({ content: '2', attachments: [] })); expect(sent.at(-1)?.[1]).toBe('2'); t += 5_000; await b.onGatewayMessage(reply({ content: 'yes', attachments: [] })); expect(sent.at(-1)?.[1]).toBe('yes\r'); t += 5_000; waiting = false; await b.onGatewayMessage(reply({ content: '2', attachments: [] })); expect(sent.at(-1)?.[1]).toBe('2\r'); b.stop(); }); it('does not save a file for a message id that is not a snowflake, and says so', async () => { const b = await make(); await b.onGatewayMessage(reply({ id: '../../x' })); expect(downloads).toHaveLength(0); expect(sent).toHaveLength(0); expect(String(posted.at(-1)?.body?.content)).toMatch(/could not be saved/); b.stop(); }); }); describe('tablesToCodeBlocks', () => { it('turns a Markdown table into an aligned code block, dropping bold and code marks', () => { const md = [ 'Results:', '', '| Variant | Decode (tokens/s) |', '|---|---:|', '| `kv-f16` | 557.0 |', '| **n-gram simple** | 554.5 |', '| n-gram mod | 550.2 |', '', 'Done.', ].join('\n'); expect(tablesToCodeBlocks(md)).toBe( [ 'Results:', '', '```', 'Variant Decode (tokens/s)', '------------- -----------------', 'kv-f16 557.0', 'n-gram simple 554.5', 'n-gram mod 550.2', '```', '', 'Done.', ].join('\n') ); }); it('leaves a table inside a code fence, prose with pipes and a table-less message alone', () => { const fenced = ['```', '| a | b |', '|---|---|', '| 1 | 2 |', '```'].join('\n'); expect(tablesToCodeBlocks(fenced)).toBe(fenced); const prose = 'Use a | b to pipe.\nThen c | d.'; expect(tablesToCodeBlocks(prose)).toBe(prose); }); it('converts a hostile separator row of 100,000 spaces in well under a second', () => { const rows = ['| a | b |', `|---${' '.repeat(100_000)}x`, '| 1 | 2 |'].join('\n'); const t0 = Date.now(); expect(tablesToCodeBlocks(rows)).toBe(rows); expect(Date.now() - t0).toBeLessThan(500); const real = ['| a | b |', `| --- ${' '.repeat(50_000)}| --- |`, '| 1 | 2 |'].join('\n'); const t1 = Date.now(); expect(tablesToCodeBlocks(real)).toContain('```'); expect(Date.now() - t1).toBeLessThan(500); }); it('leaves a wide or long table as written, quickly: 3000 columns over 3000 one-pipe rows', () => { const wide = [`|${' a |'.repeat(3000)}`, '|---|', ...Array.from({ length: 3000 }, () => '| x |')].join('\n'); const t0 = Date.now(); expect(tablesToCodeBlocks(wide)).toBe(wide); expect(Date.now() - t0).toBeLessThan(500); const tall = ['| a | b |', '|---|---|', ...Array.from({ length: 300 }, (_, i) => `| ${i} | y |`)].join('\n'); expect(tablesToCodeBlocks(tall)).toBe(tall); const big = ['| a |', '|---|', ...Array.from({ length: 200_000 }, () => '| x |')].join('\n'); const t1 = Date.now(); expect(() => tablesToCodeBlocks(big)).not.toThrow(); // no spread of 200,000 rows into Math.max expect(Date.now() - t1).toBeLessThan(1000); }); it('keeps a sentence with a pipe under a table out of the code block', () => { const out = tablesToCodeBlocks(['| a | b |', '|---|---|', '| 1 | 2 |', 'Use x | y to pipe.'].join('\n')); expect(out).toBe(['```', 'a b', '- -', '1 2', '```', 'Use x | y to pipe.'].join('\n')); }); it('pads a short row and keeps an escaped pipe in a cell', () => { const out = tablesToCodeBlocks(['| a | b |', '|---|---|', '| x \\| y |', '| p | q |'].join('\n')); expect(out).toContain('x | y'); expect(out.split('\n').every((l) => !/\s$/.test(l))).toBe(true); }); }); describe('splitForDiscord', () => { it('keeps a short message whole', () => { expect(splitForDiscord('Line one\n\n**Two**\n- a\n- b')).toEqual(['Line one\n\n**Two**\n- a\n- b']); }); it('splits a long message into posts under the limit and loses no line', () => { const lines = Array.from({ length: 120 }, (_, i) => `- item ${i} ${'x'.repeat(40)}`); const posts = splitForDiscord(lines.join('\n')); expect(posts.length).toBeGreaterThan(1); expect(posts.every((p) => p.length <= 1900)).toBe(true); expect( posts .join('\n') .split('\n') .filter((l) => l.startsWith('- item')) ).toEqual(lines); }); it('closes a code fence the cut lands inside and reopens it in the next post', () => { const body = ['```', ...Array.from({ length: 100 }, (_, i) => `row ${i} ${'y'.repeat(40)}`), '```'].join('\n'); const posts = splitForDiscord(body); expect(posts.length).toBeGreaterThan(1); for (const p of posts) expect((p.match(/^```/gm) ?? []).length % 2).toBe(0); }); it('cuts one enormous line by length, and caps the number of posts with a note', () => { const posts = splitForDiscord('z'.repeat(50_000), 1900, 3); expect(posts).toHaveLength(3); expect(posts.every((p) => p.length <= 2000)).toBe(true); // Discord's own limit, with the note included expect(posts[2]).toMatch(/continues in the tab/); }); }); describe('screenTail', () => { it("cuts the composer row and the status lines under it, but keeps a dialog's selected option", () => { const idle = [ " I'll sync the results and update the docs when the run finishes.", '✻ Crunched for 7m 55s · done 4:22 PM', '✔ Update installed · /restart to apply', '❯', ' Sonnet 5.5 in:169,629 out:751 ctx:17% cache:until 17:22', ' ⏵⏵ bypass permissions on (shift+tab to cycle)', ].join('\n'); expect(screenTail(idle, 14, 900, '❯')).toBe( [ " I'll sync the results and update the docs when the run finishes.", '✻ Crunched for 7m 55s · done 4:22 PM', '✔ Update installed · /restart to apply', ].join('\n') ); const dialog = ['Do you want to proceed?', '❯ 1. Yes', ' 2. No', 'Esc to cancel'].join('\n'); expect(screenTail(dialog, 14, 900, '❯')).toBe(dialog); expect(screenTail(idle)).toContain('❯'); // no glyph given: nothing is cut }); it('keeps the last lines of a rendered screen and drops rules, blanks and escape codes', () => { const screen = [ 'old line', '', '\u001b[1mWhich option?\u001b[0m', '─────────────────────────', ' 1. Cap GPUs', ' 2. Cap CPU', '\u001b(B', ' 4. Type something.', '━━━━━━━━━━━━━━━━━━━━━━━━━', 'Enter to select · Esc to cancel\u001b(B', ].join('\n'); expect(screenTail(screen)).toBe( [ 'old line', 'Which option?', ' 1. Cap GPUs', ' 2. Cap CPU', ' 4. Type something.', 'Enter to select · Esc to cancel', ].join('\n') ); }); it('caps lines and characters, cutting on a line', () => { const many = Array.from({ length: 40 }, (_v, i) => `line ${i}`).join('\n'); expect(screenTail(many).split('\n')).toHaveLength(14); expect(screenTail(many).endsWith('line 39')).toBe(true); const wide = Array.from({ length: 5 }, () => 'x'.repeat(400)).join('\n'); expect(screenTail(wide).length).toBeLessThanOrEqual(900); }); it('is empty for nothing', () => { expect(screenTail('')).toBe(''); expect(screenTail('\n\n ───── \n')).toBe(''); }); }); describe('ensureReceivedDir', () => { let work: string; beforeEach(() => { work = mkdtempSync(join(tmpdir(), 'discord-recv-')); }); afterEach(() => rmSync(work, { recursive: true, force: true })); it('makes the folder and ignores it with a .gitignore of *, once', async () => { const dir = await ensureReceivedDir(work); expect(dir).toBe(join(work, '.codeman-discord')); expect(readFileSync(join(dir!, '.gitignore'), 'utf8')).toBe('*\n'); }); it('leaves a .gitignore that is already there alone', async () => { mkdirSync(join(work, '.codeman-discord')); writeFileSync(join(work, '.codeman-discord', '.gitignore'), 'mine\n'); expect(await ensureReceivedDir(work)).toBe(join(work, '.codeman-discord')); expect(readFileSync(join(work, '.codeman-discord', '.gitignore'), 'utf8')).toBe('mine\n'); }); it('refuses a symlink or a plain file at the folder, and a workspace that is not a directory', async () => { const elsewhere = mkdtempSync(join(tmpdir(), 'discord-elsewhere-')); symlinkSync(elsewhere, join(work, '.codeman-discord')); expect(await ensureReceivedDir(work)).toBeNull(); expect(existsSync(join(elsewhere, '.gitignore'))).toBe(false); rmSync(elsewhere, { recursive: true, force: true }); rmSync(join(work, '.codeman-discord')); writeFileSync(join(work, '.codeman-discord'), 'x'); expect(await ensureReceivedDir(work)).toBeNull(); expect(await ensureReceivedDir(join(work, 'missing'))).toBeNull(); }); });