// @vitest-environment happy-dom // The "Billion" tab (public/modules/waiting.js): the chat bubble's badge, the chat // thread with question bubbles whose choices collapse on answer, the text box // and which question it answers, and the pinned strip of open questions. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('../public/modules/ws.js', () => ({ send: vi.fn(() => true) })); import { send } from '../public/modules/ws.js'; import { agents, setActiveSession, setBoardActive, setWaitingItems, setWaitingActive, setChatMessages, upsertChatMessage, setBillionTabOpen, activeSessionId, waitingActive, setBillionEnabled, setView } from '../public/modules/state.js'; import { updateTabs, switchToSession, removeSession, setupUpload } from '../public/modules/terminal.js'; import { readFileSync } from 'node:fs'; import { showWaiting, renderWaiting, handleWaitingError, handleChatSent, handleChatMessage, leaveWaiting, answerTarget, replyToQuestion, setBillionNotice, _resetComposer, questionGroups, setQuestionsOpen, setTelegramState, setGroupBy, _reloadQuestionPrefs } from '../public/modules/waiting.js'; import { voiceTarget, stopVoice, setupVoice } from '../public/modules/voice.js'; import { _resetReadAloud, speakingMessage, stopReading, autoReadOn } from '../public/modules/readaloud.js'; const open = (n, extra = {}) => ({ id: `w${n}`, n, text: `Question ${n}?`, at: new Date().toISOString(), status: 'open', ...extra }); const bubbleOf = (qid) => document.querySelector(`.chat-msg[data-q="${qid}"]`); // A question's bubble in the thread, carrying the same state as its item. const qMsg = (item) => ({ id: `m-${item.id}`, at: item.at, from: 'billion', text: item.text, q: { id: item.id, n: item.n, urgency: item.urgency || 'normal', status: item.status, choices: item.choices, recommended: item.recommended, answer: item.answer, answeredVia: item.answeredVia, answeredAt: item.answeredAt }, }); const questions = (...items) => { setWaitingItems(items); setChatMessages(items.map(qMsg)); renderWaiting(); }; const input = () => document.getElementById('chat-input'); const type = (text, opts = {}) => { input().value = text; input().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true, ...opts })); }; const CHAT_BUBBLE_PATH = 'M2 2h8a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H5.5L3 11V9H2a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z'; const hideWaitingState = () => document.body.classList.remove('billion-chat'); beforeEach(() => { document.body.innerHTML = '
' + '' + ''; agents.clear(); setActiveSession(null); setBoardActive(false); setWaitingActive(false); setBillionTabOpen(false); setWaitingItems([]); setChatMessages([]); hideWaitingState(); vi.clearAllMocks(); }); describe('the Billion tab', () => { it('sits next to Jobs as a chat bubble and "Billion", badged with open questions only, here and in the phone bar', () => { setWaitingItems([open(1), open(2), { ...open(3), status: 'answered', answer: 'yes' }]); updateTabs(); const tabs = [...document.querySelectorAll('.terminal-tab')]; expect(tabs.map(t => t.textContent.replace(/\d/g, ''))).toEqual(['Jobs', 'Billion']); const tab = tabs[1]; expect(tab.querySelector('.chat-icon svg path').getAttribute('d')).toBe(CHAT_BUBBLE_PATH); expect(tab.querySelector('.chat-icon .board-tab-badge').textContent).toBe('2'); expect(tab.getAttribute('aria-label')).toBe('Talk to Billion, 2 open questions'); expect(tab.title).toBe('Talk to Billion'); const nav = document.getElementById('mobile-nav-waiting-count'); const navBtn = document.querySelector('.mobile-nav [data-view="waiting"]'); expect([nav.textContent, nav.hidden, navBtn.getAttribute('aria-label')]).toEqual(['2', false, 'Talk to Billion, 2 open questions']); setWaitingItems([open(1)]); updateTabs(); expect(document.querySelector('.waiting-tab').getAttribute('aria-label')).toBe('Talk to Billion, 1 open question'); setWaitingItems(Array.from({ length: 10 }, (_, i) => open(i + 1))); updateTabs(); expect(document.querySelector('.waiting-tab .board-tab-badge').textContent).toBe('9+'); expect(document.querySelector('.waiting-tab').getAttribute('aria-label')).toBe('Talk to Billion, 10 open questions'); expect(nav.textContent).toBe('9+'); setWaitingItems([]); updateTabs(); expect(document.querySelector('.waiting-tab .board-tab-badge').hidden).toBe(true); expect(document.querySelector('.waiting-tab').classList.contains('empty')).toBe(true); expect(document.querySelector('.waiting-tab').getAttribute('aria-label')).toBe('Talk to Billion'); expect([nav.hidden, navBtn.getAttribute('aria-label'), navBtn.title]).toEqual([true, 'Talk to Billion', 'Talk to Billion']); }); it('opens on an empty thread with the text box, and lights the phone button', () => { document.body.dataset.view = 'terminal'; showWaiting(); expect(document.getElementById('waiting-board').style.display).toBe('flex'); expect(document.getElementById('job-board').style.display).toBe('none'); expect(document.getElementById('waiting-list').textContent).toBe('Nothing here yet. Say something to Billion.'); expect(input()).not.toBeNull(); expect(document.getElementById('chat-strip').hidden).toBe(true); expect(document.querySelector('.mobile-nav [data-view="waiting"]').getAttribute('aria-current')).toBe('true'); // The phone's Terminal button leaves it. leaveWaiting(); expect(document.getElementById('waiting-board').style.display).toBe('none'); expect(document.getElementById('terminal-empty').style.display).toBe('flex'); expect(document.querySelector('.mobile-nav [data-view="terminal"]').getAttribute('aria-current')).toBe('true'); }); it("the phone's Terminal button opens the first agent when none is selected, never Billion's hidden tab", () => { const agent = (extra) => ({ state: 'IDLE', termEl: document.createElement('div'), term: { dispose() {}, focus() {}, scrollToBottom() {} }, ...extra }); agents.set('b1', agent({ name: 'Billion', isBillion: true })); agents.set('w1', agent({ name: 'viper' })); agents.set('w2', agent({ name: 'cobra' })); showWaiting(); leaveWaiting(); expect(activeSessionId).toBe('w1'); expect(agents.get('w1').termEl.style.display).toBe('block'); expect(document.getElementById('terminal-empty').style.display).toBe('none'); }); }); describe("the chat tab and Billion's terminal tab", () => { // Enough of an agent for the tab strip and switchToSession. const fakeAgent = (name, extra = {}) => { const termEl = document.createElement('div'); termEl.style.display = 'none'; return { name, state: 'IDLE', termEl, term: { dispose: vi.fn(), focus() {}, scrollToBottom() {} }, ...extra }; }; const tabNames = () => [...document.querySelectorAll('.terminal-tab')].map(t => t.textContent.replace(/[\d\u00d7]/g, '')); it('is where the page opens: init shows the chat before the replay arrives', () => { const app = readFileSync('public/app.js', 'utf8'); const init = app.slice(app.indexOf('async function init()')); expect(init.indexOf('showWaiting();')).toBeGreaterThan(-1); expect(init.indexOf('showWaiting();')).toBeLessThan(init.indexOf('connect(onMessage)')); // and a reload with the chat last open comes back to it localStorage.setItem('agent007-active-tab', 's1'); showWaiting(); expect(localStorage.getItem('agent007-active-tab')).toBeNull(); }); it('is where a phone opens too, lit as Billion in the bottom bar', () => { const app = readFileSync('public/app.js', 'utf8'); expect(app.slice(app.indexOf('async function init()'))).toMatch(/showWaiting\(\);\s*setView\('terminal'\);/); document.body.dataset.view = 'office'; showWaiting(); setView('terminal'); expect(document.body.dataset.view).toBe('terminal'); expect(document.querySelector('.mobile-nav [data-view="waiting"]').getAttribute('aria-current')).toBe('true'); }); it('+ Agent starts on the only repo there is, rather than the home folder', () => { const app = readFileSync('public/app.js', 'utf8'); expect(app).toContain("if (!repoInput.value.trim() && repos.size === 1) repoInput.value = [...repos.keys()][0];"); }); it('has no close control, and orders Jobs, Billion chat, then agents with Billion first when open', () => { agents.set('w1', fakeAgent('viper')); agents.set('b1', fakeAgent('Billion', { isBillion: true })); showWaiting(); updateTabs(); expect(tabNames()).toEqual(['Jobs', 'Billion', 'viper']); expect(document.querySelector('.waiting-tab').classList.contains('active')).toBe(true); expect(document.querySelector('.waiting-tab .close-btn')).toBeNull(); // What the office click on Billion (and its explorer row) calls. switchToSession('b1'); expect(tabNames()).toEqual(['Jobs', 'Billion', 'Billion', 'viper']); const billionTab = document.querySelector('.terminal-tab[data-session-id="b1"]'); expect(billionTab.classList.contains('active')).toBe(true); expect(document.querySelector('.waiting-tab .close-btn')).toBeNull(); // Back on the chat, only the chat tab is lit. showWaiting(); updateTabs(); expect(document.querySelectorAll('.terminal-tab.active').length).toBe(1); switchToSession('b1'); // Its close hides the tab and goes back to the chat; the session stays. billionTab.querySelector('.close-btn').click(); expect(send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'kill' })); expect(agents.has('b1')).toBe(true); expect(tabNames()).toEqual(['Jobs', 'Billion', 'viper']); expect([activeSessionId, waitingActive]).toEqual([null, true]); expect(agents.get('b1').termEl.style.display).toBe('none'); }); it("closing Billion's tab under the job board leaves the board up", () => { agents.set('b1', fakeAgent('Billion', { isBillion: true })); switchToSession('b1'); setBoardActive(true); document.querySelector('.terminal-tab[data-session-id="b1"] .close-btn').click(); expect([activeSessionId, waitingActive, agents.has('b1')]).toEqual([null, false, true]); expect(tabNames()).toEqual(['Jobs', 'Billion']); }); it("lands on the chat, not a hidden Billion, when the last shown terminal closes", () => { agents.set('b1', fakeAgent('Billion', { isBillion: true })); agents.set('w1', fakeAgent('viper', { state: 'DISCONNECTED' })); document.body.insertAdjacentHTML('beforeend', '
'); switchToSession('w1'); removeSession('w1'); expect([activeSessionId, waitingActive]).toEqual([null, true]); expect(tabNames()).toEqual(['Jobs', 'Billion']); }); }); describe('the thread', () => { beforeEach(() => showWaiting()); it('puts the owner on the right and Billion on the left, in time order, text never as HTML, voice marked', () => { setChatMessages([ { id: 'a', at: new Date().toISOString(), from: 'owner', via: 'app', text: 'hi' }, { id: 'b', at: new Date().toISOString(), from: 'billion', text: 'Hello.' }, { id: 'c', at: new Date().toISOString(), from: 'owner', via: 'telegram', voice: true, text: 'from the phone' }, ]); renderWaiting(); const rows = [...document.querySelectorAll('.chat-msg')]; expect(rows.map(r => [r.classList.contains('mine'), r.querySelector('.chat-text').textContent])).toEqual([ [true, 'hi'], [false, 'Hello.'], [true, 'from the phone'], ]); expect(document.querySelector('.chat-text b')).toBeNull(); expect(rows[2].querySelector('.chat-meta').textContent).toMatch(/^\(voice\) · Telegram · /); // A broadcast adds to the end. upsertChatMessage({ id: 'd', at: new Date().toISOString(), from: 'billion', text: 'Got it.' }); renderWaiting(); expect(document.querySelector('.chat-msg:last-child .chat-text').textContent).toBe('Got it.'); }); it('shows a question with its choices, recommended first, and a tap answers it', () => { questions(open(1, { text: 'Buy?', choices: ['no', 'yes'], recommended: 'yes' })); const b = bubbleOf('w1'); expect(b.querySelector('.waiting-card-n').textContent).toBe('Q1'); const choices = [...b.querySelectorAll('.waiting-choice')]; expect(choices.map(c => c.textContent)).toEqual(['yesrecommended', 'no']); choices[1].click(); expect(send).toHaveBeenCalledWith({ type: 'waiting-answer', id: 'w1', answer: 'no' }); // Held until the server answers: no double send. bubbleOf('w1').querySelector('.waiting-choice').click(); expect(send).toHaveBeenCalledTimes(1); }); it('collapses the buttons to "you answered" once answered, and keeps it as history', () => { questions({ ...open(1, { choices: ['yes', 'no'] }), status: 'answered', answer: 'yes', answeredVia: 'app' }, { ...open(2), status: 'answered', answer: 'later', answeredVia: 'telegram' }); expect(bubbleOf('w1').querySelector('.waiting-choice')).toBeNull(); expect(bubbleOf('w1').querySelector('.chat-answered').textContent).toBe('you answered: yes'); expect(bubbleOf('w2').querySelector('.chat-answered').textContent).toBe('you answered: later (on Telegram)'); }); it('shows a refused tap on the question, which stays open, and dismisses with Dismiss', () => { questions(open(5, { choices: ['yes', 'no'] })); send.mockReturnValueOnce(false); bubbleOf('w5').querySelector('.waiting-choice').click(); expect(bubbleOf('w5').querySelector('.waiting-error').textContent).toMatch(/Not connected/); expect(bubbleOf('w5').querySelector('.waiting-choice').disabled).toBe(false); bubbleOf('w5').querySelector('.waiting-choice').click(); handleWaitingError({ id: 'w5', error: 'Billion is not running' }); expect(bubbleOf('w5').querySelector('.waiting-error').textContent).toBe('Billion is not running'); bubbleOf('w5').querySelector('.waiting-dismiss').click(); expect(send).toHaveBeenLastCalledWith({ type: 'waiting-dismiss', id: 'w5' }); }); }); describe('the text box', () => { beforeEach(() => showWaiting()); it('has the phone button between the mic and Send, and the call bar beside the row', () => { const row = document.getElementById('chat-compose-row'); const ids = [...row.children].map(c => c.id); expect(ids.slice(-3)).toEqual(['chat-mic', 'chat-talk', 'chat-send']); expect(document.getElementById('talk-bar').nextElementSibling).toBe(row); expect(document.querySelector('.talk-bar:not(#chat-compose *)')).not.toBeNull(); }); it('sends on Enter, not on Shift+Enter, and empties only once the server took it', () => { type('hello', { shiftKey: true }); expect(send).not.toHaveBeenCalled(); type(' hello Billion '); expect(send).toHaveBeenCalledWith({ type: 'chat-send', nonce: expect.any(String), text: 'hello Billion' }); const { nonce } = send.mock.calls[0][0]; expect(input().value).toBe(' hello Billion '); expect(document.getElementById('chat-send').disabled).toBe(true); handleChatSent({ nonce }); expect(input().value).toBe(''); expect(document.getElementById('chat-send').disabled).toBe(false); }); it('keeps the text and says why when refused, and says when Billion is not running', () => { expect(input().placeholder).toBe('Start Billion to send'); type('are you there?'); handleChatSent({ nonce: send.mock.calls[0][0].nonce, error: 'Billion is not running; start it, then send again.' }); expect(input().value).toBe('are you there?'); expect(document.getElementById('chat-error').textContent).toBe('Billion is not running; start it, then send again.'); agents.set('b', { isBillion: true }); renderWaiting(); expect(input().placeholder).toBe('Message Billion, or /command to run one'); // A phone's box is too narrow for the /command hint. const matchMedia = window.matchMedia; window.matchMedia = (q) => ({ matches: q === '(max-width: 700px)' }); renderWaiting(); expect(input().placeholder).toBe('Message Billion'); window.matchMedia = matchMedia; agents.delete('b'); }); it('counts a stopped Billion as not running', () => { agents.set('b', { isBillion: true, state: 'DISCONNECTED' }); renderWaiting(); expect(input().placeholder).toBe('Start Billion to send'); agents.delete('b'); }); it('says above the box why Billion cannot talk, with the button past it', () => { const bar = () => document.getElementById('chat-notice'); setBillionEnabled(true); renderWaiting(); // Never started: Start. expect(bar().hidden).toBe(false); expect(bar().textContent).toBe('Billion is not running.Start Billion'); bar().querySelector('button').click(); expect(send).toHaveBeenLastCalledWith({ type: 'billion-start' }); // Its CLI missing: the reason, and still Start. agents.set('b', { isBillion: true, state: 'DISCONNECTED' }); setBillionNotice('b', 'Billion runs on Claude Code, which is not installed.'); expect(bar().textContent).toBe('Billion runs on Claude Code, which is not installed.Start Billion'); // Running but logged out: its terminal, where the sign-in is. agents.set('b', { isBillion: true, state: 'IDLE', termEl: document.createElement('div'), term: { scrollToBottom() {}, focus() {} } }); setBillionNotice('b', 'Claude Code says it is not logged in.'); expect(bar().querySelector('button').textContent).toBe("Open Billion's terminal"); bar().querySelector('button').click(); expect(activeSessionId).toBe('b'); expect(document.body.dataset.view).toBe('terminal'); // Ready: gone. showWaiting(); setBillionNotice('b', null); expect(bar().hidden).toBe(true); agents.delete('b'); setBillionEnabled(undefined); }); it('hides the empty thread line while a notice says Billion cannot hear', () => { const empty = () => document.querySelector('#waiting-list .waiting-empty'); agents.set('b', { isBillion: true, state: 'IDLE' }); renderWaiting(); expect(empty().hidden).toBe(false); setBillionNotice('b', 'Claude Code says it is not logged in.'); expect(empty().hidden).toBe(true); renderWaiting(); expect(empty().hidden).toBe(true); setBillionNotice('b', null); expect(empty().hidden).toBe(false); agents.delete('b'); }); it('sends a plain message with questions open; only Reply makes it an answer, and × leaves it', () => { questions(open(1), open(2, { urgency: 'blocking', choices: ['yes', 'no'] })); expect(answerTarget()).toBeNull(); expect(document.getElementById('chat-target').hidden).toBe(true); expect(input().placeholder).toBe('Start Billion to send'); type('not about Q2'); expect(send).toHaveBeenLastCalledWith({ type: 'chat-send', nonce: expect.any(String), text: 'not about Q2' }); handleChatSent({ nonce: send.mock.calls.at(-1)[0].nonce }); // Reply sits beside the choices, and alone on a question without them. expect([...bubbleOf('w1').querySelector('.chat-q-foot').children].map(c => c.textContent)).toEqual(['Reply', 'Dismiss']); bubbleOf('w2').querySelector('.chat-reply').click(); expect(answerTarget().id).toBe('w2'); expect(document.getElementById('chat-target').textContent).toMatch(/^Answers Q2: /); type('go ahead'); expect(send).toHaveBeenLastCalledWith({ type: 'chat-send', nonce: expect.any(String), text: 'go ahead', answers: 'w2' }); handleChatSent({ nonce: send.mock.calls.at(-1)[0].nonce }); bubbleOf('w1').querySelector('.chat-reply').click(); expect(answerTarget().id).toBe('w1'); document.querySelector('.chat-target-x').click(); expect(document.getElementById('chat-target').hidden).toBe(true); type('just chatting'); expect(send).toHaveBeenLastCalledWith({ type: 'chat-send', nonce: expect.any(String), text: 'just chatting' }); handleChatSent({ nonce: send.mock.calls.at(-1)[0].nonce }); }); it('drops the Reply once its question is answered elsewhere', () => { questions(open(1)); bubbleOf('w1').querySelector('.chat-reply').click(); questions({ ...open(1), status: 'answered', answer: 'yes', answeredVia: 'telegram', answeredAt: new Date().toISOString() }); expect(answerTarget()).toBeNull(); expect(document.getElementById('chat-target').hidden).toBe(true); }); }); describe('Undo', () => { beforeEach(() => showWaiting()); const answered = (n, msAgo, via = 'app') => ({ ...open(n), status: 'answered', answer: 'yes', answeredVia: via, answeredAt: new Date(Date.now() - msAgo).toISOString() }); it('shows for a minute after answering and reopens the question', () => { questions(answered(1, 10e3), answered(2, 61e3), answered(3, 5e3, 'terminal')); expect(bubbleOf('w2').querySelector('.chat-undo')).toBeNull(); expect(bubbleOf('w3').querySelector('.chat-undo')).toBeNull(); bubbleOf('w1').querySelector('.chat-undo').click(); expect(send).toHaveBeenLastCalledWith({ type: 'waiting-reopen', id: 'w1' }); expect(bubbleOf('w1').querySelector('.chat-undo').disabled).toBe(true); handleWaitingError({ id: 'w1', error: 'Billion is not running' }); expect(bubbleOf('w1').querySelector('.waiting-error').textContent).toBe('Billion is not running'); expect(bubbleOf('w1').querySelector('.chat-undo').disabled).toBe(false); // Reopened by the server: open again, with its Reply. questions(open(1)); expect(bubbleOf('w1').querySelector('.waiting-error')).toBeNull(); expect(bubbleOf('w1').querySelector('.chat-reply')).not.toBeNull(); }); it('goes when its minute is up', () => { vi.useFakeTimers(); try { questions(answered(1, 0)); expect(bubbleOf('w1').querySelector('.chat-undo')).not.toBeNull(); vi.advanceTimersByTime(61e3); expect(bubbleOf('w1').querySelector('.chat-undo')).toBeNull(); } finally { vi.useRealTimers(); } }); }); describe('the open questions chip', () => { beforeEach(() => { setQuestionsOpen(false, { focus: false }); localStorage.clear(); showWaiting(); }); it('counts the open questions, "!" when one is blocking, and hides when none are open', () => { questions(open(1, { urgency: 'low' }), open(2), { ...open(3), status: 'answered', answer: 'a' }); const strip = document.getElementById('chat-strip'); expect([strip.hidden, strip.textContent, strip.classList.contains('blocking')]).toEqual([false, '2open questions', false]); expect(strip.querySelector('.chat-wide').textContent).toBe(' questions'); // a phone shows "2 open" questions(open(1), open(2, { urgency: 'blocking' })); expect([strip.textContent, strip.classList.contains('blocking')]).toEqual(['!2open questions', true]); expect(strip.getAttribute('aria-label')).toBe('2 open questions, blocking: show them by project'); questions(open(1)); expect(strip.textContent).toBe('1open question'); questions({ ...open(7), status: 'answered', answer: 'a' }); expect(strip.hidden).toBe(true); expect(strip.children.length).toBe(0); }); it('is a button in the bar that opens and closes the panel, saying which', () => { document.body.innerHTML = '
'; _resetComposer(); showWaiting(); questions(open(1), open(2), open(3)); const strip = document.getElementById('chat-strip'); expect(strip.closest('#billion-bar')).not.toBeNull(); expect([strip.tagName, strip.type, strip.getAttribute('aria-controls'), strip.getAttribute('aria-expanded')]).toEqual(['BUTTON', 'button', 'chat-questions', 'false']); expect(strip.getAttribute('aria-label')).toBe('3 open questions: show them by project'); strip.click(); expect(document.getElementById('chat-questions').hidden).toBe(false); expect([strip.getAttribute('aria-expanded'), strip.getAttribute('aria-label')]).toEqual(['true', '3 open questions: hide them by project']); strip.click(); expect(document.getElementById('chat-questions').hidden).toBe(true); }); it('marks blocking with a bold "!", leaves normal plain, and dims low', () => { questions(open(1, { urgency: 'blocking' }), open(2, { urgency: 'normal' }), open(3, { urgency: 'low' })); const n = (id) => bubbleOf(id).querySelector('.waiting-card-n'); expect(n('w1').textContent).toBe('! Q1'); expect(n('w1').querySelector('b.waiting-urgent').textContent).toBe('!'); expect(n('w1').title).toBe('blocking: a worker or merge is waiting'); expect([n('w2').textContent, n('w2').title, n('w2').className]).toEqual(['Q2', '', 'waiting-card-n']); expect([n('w3').textContent, n('w3').title, n('w3').classList.contains('low')]).toEqual(['Q3', 'optional', true]); }); }); describe('read aloud and dictation in the tab', () => { let spoken; let recs; const flush = () => new Promise(r => setTimeout(r, 0)); const result = (transcript, isFinal) => ({ resultIndex: 0, results: [{ isFinal, 0: { transcript } }] }); beforeEach(() => { spoken = []; recs = []; localStorage.clear(); window.SpeechSynthesisUtterance = class { constructor(text) { this.text = text; } }; window.speechSynthesis = { speak: vi.fn(u => spoken.push(u)), cancel: vi.fn(), resume: vi.fn(), getVoices: () => [] }; window.SpeechRecognition = class { start() { recs.push(this); } abort() {} stop() {} }; Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true }); Object.defineProperty(navigator, 'mediaDevices', { value: { getUserMedia: vi.fn(async () => ({ getTracks: () => [] })) }, configurable: true }); _resetReadAloud(); // The composer is built on the first render; rebuild it with the stubs in place. document.body.innerHTML += ''; showWaiting(); }); const billion = (id, text) => ({ id, at: new Date().toISOString(), from: 'billion', text }); it('puts a speaker on Billion\'s messages only, which reads it and turns into Stop', () => { setChatMessages([billion('m1', 'Merged **PR 3**: https://x.y/3'), { id: 'm2', at: new Date().toISOString(), from: 'owner', via: 'app', text: 'ok' }]); renderWaiting(); const speakers = document.querySelectorAll('.chat-speak'); expect(speakers.length).toBe(1); const btn = speakers[0]; expect([btn.getAttribute('aria-label'), btn.getAttribute('aria-pressed')]).toEqual(['Read aloud', 'false']); btn.click(); expect(spoken.map(u => u.text)).toEqual(['Merged PR 3: link.']); expect([btn.getAttribute('aria-label'), btn.getAttribute('aria-pressed'), btn.classList.contains('speaking')]).toEqual(['Stop reading', 'true', true]); btn.click(); expect(speakingMessage()).toBeNull(); expect(btn.getAttribute('aria-label')).toBe('Read aloud'); }); it('reads a question with its choices', () => { questions(open(51, { choices: ['Not yet', 'Done'], recommended: 'Done', text: 'Is Q50 done?' })); bubbleOf('w51').querySelector('.chat-speak').click(); expect(spoken[0].text).toBe('Question 51. Is question 50 done? Choices: Not yet, Done; recommended: Done.'); }); it('reads new Billion messages aloud only with the header toggle on, and remembers it', () => { const toggle = document.getElementById('chat-autoread'); expect(toggle.getAttribute('aria-pressed')).toBe('false'); handleChatMessage(billion('m1', 'First.')); expect(spoken).toEqual([]); toggle.click(); expect([toggle.getAttribute('aria-pressed'), localStorage.getItem('agent007-read-aloud')]).toEqual(['true', '1']); spoken.at(-1).onend(); // the "Reading new messages aloud" confirmation handleChatMessage(billion('m2', 'Second.')); handleChatMessage({ id: 'm3', at: new Date().toISOString(), from: 'owner', via: 'app', text: 'mine' }); handleChatMessage(billion('m2', 'Second, edited.')); // not new expect(spoken.map(u => u.text)).toEqual(['Reading new messages aloud.', 'Second.']); }); it('after a reload, shows Resume reading instead of failing silently', () => { localStorage.setItem('agent007-read-aloud', '1'); renderWaiting(); handleChatMessage(billion('m1', 'First.')); const resume = document.getElementById('chat-resume'); expect([resume.hidden, resume.textContent]).toEqual([false, 'Resume reading (1 new)']); expect(spoken).toEqual([]); resume.click(); expect(spoken.map(u => u.text)).toEqual(['First.']); expect(resume.hidden).toBe(true); }); it('leaving the tab stops reading', () => { setChatMessages([billion('m1', 'Long.')]); renderWaiting(); document.querySelector('.chat-speak').click(); leaveWaiting(); expect(speakingMessage()).toBeNull(); stopReading(); }); it('the bar\'s speaker is the read-aloud switch itself: one tap on, one off, named, labelled and accent', () => { const toggle = document.getElementById('chat-autoread'); expect(toggle.closest('#chat-head')).not.toBeNull(); expect(document.querySelector('[popover]')).toBeNull(); expect([toggle.type, toggle.querySelector('svg') !== null, toggle.querySelector('.chat-autoread-label').textContent]).toEqual(['button', true, 'Read aloud']); expect([toggle.getAttribute('aria-label'), toggle.title]).toEqual(['Read new messages aloud', 'Read new messages aloud']); expect([toggle.getAttribute('aria-pressed'), toggle.classList.contains('on')]).toEqual(['false', false]); toggle.click(); expect([toggle.getAttribute('aria-pressed'), toggle.classList.contains('on'), localStorage.getItem('agent007-read-aloud')]).toEqual(['true', true, '1']); expect(toggle.getAttribute('aria-label')).toBe('Read new messages aloud'); // the name stays; aria-pressed carries the state toggle.click(); expect([toggle.getAttribute('aria-pressed'), toggle.classList.contains('on'), localStorage.getItem('agent007-read-aloud')]).toEqual(['false', false, null]); // The voice is picked in Settings, not here. expect(document.querySelector('#waiting-board select')).toBeNull(); // The word shows only where the bar has room. const css = readFileSync('public/style.css', 'utf8'); expect(css).toMatch(/@container billion \(max-width: 720px\) \{\s*\.chat-autoread \{[^}]*\}\s*\.chat-autoread-label \{ display: none; \}/); }); it('a tap on the speaker while something is read stops it and leaves reading off; the next tap turns it on', () => { const toggle = document.getElementById('chat-autoread'); const note = document.getElementById('chat-toast'); expect(document.getElementById('chat-stop-reading')).toBeNull(); // no Stop in the bar // Off, with one message being read from its own speaker. setChatMessages([billion('m1', 'Long.')]); renderWaiting(); document.querySelector('.chat-speak').click(); expect(speakingMessage()).toBe('m1'); toggle.click(); expect([speakingMessage(), toggle.getAttribute('aria-pressed'), note.textContent]).toEqual([null, 'false', 'Read aloud: off']); expect(window.speechSynthesis.cancel).toHaveBeenCalled(); // On, and reading a new message as it arrives. toggle.click(); expect([toggle.getAttribute('aria-pressed'), note.textContent]).toEqual(['true', 'Read aloud: on']); spoken.at(-1).onend(); // the confirmation handleChatMessage(billion('m2', 'Second.')); expect(speakingMessage()).toBe('m2'); toggle.click(); expect([speakingMessage(), toggle.getAttribute('aria-pressed'), autoReadOn(), note.textContent]).toEqual([null, 'false', false, 'Read aloud: off']); toggle.click(); expect([toggle.getAttribute('aria-pressed'), note.textContent]).toEqual(['true', 'Read aloud: on']); }); it('each tap on the speaker says the new state in a toast, which goes after a moment', () => { vi.useFakeTimers(); try { const note = document.getElementById('chat-toast'); expect([note.hidden, note.getAttribute('role')]).toEqual([true, 'status']); renderWaiting(); expect(note.hidden).toBe(true); // a render alone says nothing document.getElementById('chat-autoread').click(); expect([note.hidden, note.textContent]).toEqual([false, 'Read aloud: on']); vi.advanceTimersByTime(1000); document.getElementById('chat-autoread').click(); expect([note.hidden, note.textContent]).toEqual([false, 'Read aloud: off']); vi.advanceTimersByTime(1500); expect(note.hidden).toBe(false); // the second tap restarted the clock vi.advanceTimersByTime(400); expect(note.hidden).toBe(true); } finally { vi.useRealTimers(); } }); it('the box is not a login field to a password manager', () => { for (const id of ['chat-input']) { expect(document.getElementById(id).getAttribute('autocomplete'), id).toBe('off'); } }); it('the box, mic and Send share one control height token, the bar\'s controls another', () => { for (const id of ['chat-input', 'chat-mic', 'chat-send']) { expect(document.getElementById(id).classList.contains('chat-control'), id).toBe(true); } for (const id of ['chat-strip', 'chat-resume', 'chat-autoread']) { expect(document.getElementById(id).classList.contains('billion-bar-control'), id).toBe(true); } const css = readFileSync('public/style.css', 'utf8'); expect(css).toMatch(/\.chat-control \{ min-height: var\(--chat-control-h\); \}/); expect(css).toMatch(/\.billion-bar-control \{ min-height: var\(--bar-control-h\); \}/); expect(css).toMatch(/\.chat-mic \{[^}]*width: var\(--chat-control-h\);/); // No rule anywhere, media queries included, gives one of them its own // height to drift from the token. const controls = /(#chat-input|\.chat-mic|\.waiting-send|\.chat-autoread|\.chat-resume|\.chat-strip)(?![-\w])/; const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)].filter(([, sel]) => controls.test(sel)); expect(rules.length).toBeGreaterThan(5); for (const [, sel, body] of rules) expect(body, sel.trim()).not.toMatch(/(^|[\s;])(min-|max-)?height:/); expect(css).toMatch(/\.chat-compose-row \{[^}]*gap: var\(--chat-gap\);/); expect(css).toMatch(/\.chat-head \{[^}]*gap: var\(--chat-gap\);/); }); it('the mic beside the box dictates into it: interim greyed, final appended, nothing sent', async () => { const mic = document.getElementById('chat-mic'); expect(mic.getAttribute('aria-label')).toBe('Dictate a reply'); input().value = 'Done'; mic.click(); await flush(); expect(voiceTarget()).toBe('chat'); expect([mic.getAttribute('aria-pressed'), mic.classList.contains('listening')]).toEqual(['true', true]); const live = document.getElementById('chat-voice'); recs[0].onresult(result('and merge it', false)); expect([live.style.display, live.textContent, input().value]).toEqual(['flex', 'and merge it', 'Done']); recs[0].onresult(result('and merge it', true)); expect(input().value).toBe('Done and merge it '); expect(send).not.toHaveBeenCalled(); mic.click(); expect([voiceTarget(), mic.getAttribute('aria-pressed'), live.style.display]).toEqual([null, 'false', 'none']); }); it('dictated text answers the question the box is set to, like typed text', async () => { questions(open(3)); replyToQuestion('w3'); document.getElementById('chat-mic').click(); await flush(); recs[0].onresult(result('not yet', true)); stopVoice(); type(input().value); expect(send).toHaveBeenCalledWith(expect.objectContaining({ type: 'chat-send', text: 'not yet', answers: 'w3' })); }); it('the terminal\'s mic button is still wired up by setupVoice', () => { document.body.insertAdjacentHTML('beforeend', ''); setupVoice(); expect(typeof document.getElementById('btn-voice').onclick).toBe('function'); }); it('a refused chat mic leaves the terminal\'s own stops working', async () => { agents.set('s1', { ownerId: null, state: 'IDLE', term: { focus() {} } }); setActiveSession('s1'); document.body.insertAdjacentHTML('beforeend', '
'); setupVoice(); document.getElementById('btn-voice').onclick(); await flush(); expect(voiceTarget()).toBe('terminal'); setWaitingActive(false); // the chat mic is refused: no Billion tab document.getElementById('chat-mic').click(); expect(voiceTarget()).toBeNull(); // pressing another mic stopped the terminal's stopVoice({ only: 'terminal' }); setWaitingActive(true); }); it('the terminal\'s stops leave the box\'s mic alone; leaving the tab stops it', async () => { document.getElementById('chat-mic').click(); await flush(); stopVoice({ only: 'terminal', notice: 'switched agents' }); expect(voiceTarget()).toBe('chat'); leaveWaiting(); expect(voiceTarget()).toBeNull(); }); }); // Pasted, dropped or picked files: chips above the box, sent with the message. describe('attachments', () => { beforeEach(() => { _resetComposer(); agents.set('b1', { isBillion: true, state: 'WAITING', termEl: document.createElement('div') }); showWaiting(); }); // Module state: a send an earlier test left in flight, or files it left attached. afterEach(() => _resetComposer()); URL.createObjectURL ??= () => 'blob:x'; URL.revokeObjectURL ??= () => {}; const png = (name = 'image.png') => new File(['PNG'], name, { type: 'image/png' }); const pdf = (name = 'notes.pdf', size = 3) => new File(['x'.repeat(size)], name, { type: 'application/pdf' }); const chips = () => [...document.querySelectorAll('#chat-files .chat-file-chip')]; const read = () => vi.waitFor(() => expect(document.getElementById('chat-send').disabled).toBe(false)); const dataTransfer = (files) => ({ files, types: ['Files'], items: [], dropEffect: '' }); const fire = (target, type, init) => { const e = new Event(type, { bubbles: true, cancelable: true }); Object.assign(e, init); target.dispatchEvent(e); return e; }; it('a pasted screenshot becomes a thumbnail chip, named so a second one does not replace it', async () => { // The terminal's paste handler (an upload to the selected agent) stays out of it. setupUpload(); agents.set('a1', { name: 'Viper', repoPath: '/r', termEl: document.createElement('div') }); setActiveSession('a1'); const blob = png(); const e = fire(input(), 'paste', { clipboardData: { files: [blob], items: [{ type: 'image/png', getAsFile: () => blob }] } }); expect(e.defaultPrevented).toBe(true); await new Promise(r => setTimeout(r, 30)); // the upload would go once its FileReader is done expect(send).not.toHaveBeenCalled(); expect(chips()).toHaveLength(1); expect(chips()[0].querySelector('img.chat-file-thumb').alt).toMatch(/^screenshot-\d+\.png$/); }); it('files dropped on the tab light it while dragged and become chips; the paperclip picks too', () => { const board = document.getElementById('waiting-board'); fire(board, 'dragenter', { dataTransfer: dataTransfer([]) }); expect(board.classList.contains('dropping')).toBe(true); const drop = fire(board, 'drop', { dataTransfer: dataTransfer([pdf('a.pdf'), pdf('b.pdf', 2048)]) }); expect(drop.defaultPrevented).toBe(true); expect(board.classList.contains('dropping')).toBe(false); expect(chips().map(c => c.textContent)).toEqual(['a.pdf3 B×', 'b.pdf2 KB×']); const pick = document.getElementById('chat-attach-input'); const click = vi.spyOn(pick, 'click').mockImplementation(() => {}); document.getElementById('chat-attach').click(); expect(click).toHaveBeenCalled(); Object.defineProperty(pick, 'files', { value: [pdf('c.pdf')], configurable: true }); pick.dispatchEvent(new Event('change')); expect(chips()).toHaveLength(3); chips()[0].querySelector('.chat-file-remove').click(); expect(chips().map(c => c.querySelector('.chat-file-name').textContent)).toEqual(['b.pdf', 'c.pdf']); }); it('refuses past the job-attachment limits with a line under the box', () => { const big = pdf('big.pdf'); Object.defineProperty(big, 'size', { value: 10 * 1024 * 1024 + 1 }); fire(document.getElementById('waiting-board'), 'drop', { dataTransfer: dataTransfer([big]) }); expect(chips()).toHaveLength(0); expect(document.getElementById('chat-error').textContent).toBe('big.pdf is too large (max 10MB)'); }); it('sends files with no text, and clears the chips once the server took them', async () => { fire(document.getElementById('waiting-board'), 'drop', { dataTransfer: dataTransfer([pdf('a.pdf')]) }); await vi.waitFor(() => { type(''); expect(send).toHaveBeenCalledWith({ type: 'chat-send', nonce: expect.any(String), text: '', files: [{ name: 'a.pdf', type: 'application/pdf', data: btoa('xxx') }] }); }); handleChatSent({ nonce: send.mock.calls.at(-1)[0].nonce }); expect(chips()).toHaveLength(0); await read(); }); it('shows the owner\'s files in the bubble: images open full size, others download', () => { setChatMessages([{ id: 'm1', at: new Date().toISOString(), from: 'owner', via: 'app', text: '', files: [ { name: 'shot.png', size: 3, type: 'image/png' }, { name: 'notes.pdf', size: 2048, type: 'application/pdf' }, ] }]); renderWaiting(); const image = document.querySelector('.chat-msg .chat-file-image'); expect([image.getAttribute('href'), image.target, image.querySelector('img').getAttribute('src')]) .toEqual(['/api/chat/m1/files/shot.png', '_blank', '/api/chat/m1/files/shot.png']); const file = document.querySelector('.chat-msg a.chat-file-chip'); expect([file.getAttribute('href'), file.download, file.textContent]).toEqual(['/api/chat/m1/files/notes.pdf', 'notes.pdf', 'notes.pdf2 KB']); expect(document.querySelector('.chat-msg .chat-text')).toBeNull(); }); }); describe('the Open questions panel', () => { beforeEach(() => { localStorage.clear(); _reloadQuestionPrefs(); setQuestionsOpen(false, { focus: false }); showWaiting(); }); const at = (hoursAgo) => new Date(Date.now() - hoursAgo * 3600e3).toISOString(); const panel = () => document.getElementById('chat-questions'); const sections = () => [...panel().querySelectorAll('.chat-questions-group')].map(g => [ g.querySelector('.chat-questions-name').textContent, g.querySelector('.chat-questions-count').textContent, [...g.querySelectorAll('.waiting-card-n')].map(n => n.textContent).join(' '), ]); const header = (name) => panel().querySelector(`.chat-questions-project[data-name="${name}"]`); // Sections start closed: tap each one open. const expand = () => { for (const name of questionGroups().map(g => g.name)) if (header(name).getAttribute('aria-expanded') === 'false') header(name).click(); }; const seed = () => questions( open(1, { project: 'finnamon', type: 'finance', at: at(30) }), open(2, { project: 'agent-007', type: 'engineering', at: at(2) }), open(3, { project: 'agent-007', type: 'engineering', urgency: 'blocking', at: at(1) }), open(4, { at: at(50), text: 'First line\nsecond line' }), // no project: general; no type: other open(5, { project: 'finnamon', type: 'finance', urgency: 'low', at: at(60) }), open(6, { project: 'finnamon', type: 'outreach', at: at(40), choices: ['yes', 'no'], recommended: 'no' }), { ...open(7, { project: 'mirage', type: 'marketing' }), status: 'answered', answer: 'a' }, ); it('groups open questions by project: blocking first by their most urgent, then oldest; rows urgency then oldest', () => { seed(); setQuestionsOpen(true); expand(); expect(sections()).toEqual([ ['agent-007', '2', '! Q3 Q2'], ['general', '1', 'Q4'], ['finnamon', '3', 'Q6 Q1 Q5'], ]); // Rows: the first line of the text and its age. const row = panel().querySelector('[data-q="w4"]'); expect(row.querySelector('.chat-questions-text').textContent).toBe('First line'); expect(row.querySelector('.chat-questions-age').textContent).toBe('2d'); expect(panel().querySelector('[data-q="w3"] .chat-questions-age').textContent).toBe('1h'); // A project with nothing open has no section; none at all, no panel. expect(questionGroups().map(g => g.name)).not.toContain('mirage'); questions({ ...open(7), status: 'answered', answer: 'a' }); expect(panel().hidden).toBe(true); }); it('answers from a row: the choices (recommended first) and Reply, which arms the box', () => { seed(); setQuestionsOpen(true); expand(); const row = panel().querySelector('[data-q="w6"]'); expect([...row.querySelectorAll('.waiting-choice')].map(b => b.firstChild.textContent)).toEqual(['no', 'yes']); row.querySelector('.waiting-choice').click(); expect(send).toHaveBeenCalledWith({ type: 'waiting-answer', id: 'w6', answer: 'no' }); panel().querySelector('[data-q="w2"] .chat-reply').click(); expect(answerTarget().id).toBe('w2'); expect(panel().hidden).toBe(false); // the box sits below it on a desktop expect(document.activeElement).toBe(input()); }); it('tapping a row\'s text jumps to its bubble and closes the panel', () => { seed(); setQuestionsOpen(true); expand(); expect(document.getElementById('waiting-board').classList.contains('questions-open')).toBe(true); const scroll = vi.fn(); window.HTMLElement.prototype.scrollIntoView = scroll; panel().querySelector('[data-q="w5"] .chat-questions-line').click(); expect(panel().hidden).toBe(true); expect(document.getElementById('waiting-board').classList.contains('questions-open')).toBe(false); expect(scroll).toHaveBeenCalled(); expect(bubbleOf('w5').classList.contains('flash')).toBe(true); }); it('closes with × and with Esc, and remembers open or closed in this browser', () => { seed(); setQuestionsOpen(true); expect(localStorage.getItem('agent007-questions-open')).toBe('1'); expect(document.activeElement.id).toBe('chat-questions-close'); document.getElementById('chat-questions-close').click(); expect(panel().hidden).toBe(true); expect(localStorage.getItem('agent007-questions-open')).toBe('0'); expect(document.activeElement.id).toBe('chat-strip'); setQuestionsOpen(true); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); expect(panel().hidden).toBe(true); }); it('opens from the tab\'s badge, which still counts every open question', () => { seed(); leaveWaiting(); updateTabs(); const badge = document.querySelector('.waiting-tab .chat-badge'); expect(badge.textContent).toBe('6'); badge.click(); expect(waitingActive).toBe(true); expect(panel().hidden).toBe(false); }); it('groups by type with the switch, which this browser remembers across a reload', () => { seed(); setQuestionsOpen(true); const by = () => [...panel().querySelectorAll('.chat-questions-by button')].map(b => [b.textContent, b.getAttribute('aria-pressed')]); expect(by()).toEqual([['by project', 'true'], ['by type', 'false']]); panel().querySelectorAll('.chat-questions-by button')[1].click(); expect(by()).toEqual([['by project', 'false'], ['by type', 'true']]); expect(localStorage.getItem('agent007-questions-by')).toBe('type'); // The strip's label names the grouping. expect(document.getElementById('chat-strip').getAttribute('aria-label')).toBe('6 open questions, blocking: hide them by type'); expand(); // The same rows, regrouped; a question with no type is "other". expect(sections()).toEqual([ ['engineering', '2', '! Q3 Q2'], ['other', '1', 'Q4'], ['outreach', '1', 'Q6'], ['finance', '2', 'Q1 Q5'], ]); _reloadQuestionPrefs(); renderWaiting(); expect(by()[1][1]).toBe('true'); expect(questionGroups().map(g => g.name)).toEqual(['engineering', 'other', 'outreach', 'finance']); expect(questionGroups(undefined, 'project').map(g => g.name)).toEqual(['agent-007', 'general', 'finnamon']); // Anything else stored reads as by project. localStorage.setItem('agent007-questions-by', 'bogus'); _reloadQuestionPrefs(); renderWaiting(); expect(by()[0][1]).toBe('true'); }); it('starts every section closed, with its count and a "!" when a blocking question is inside; a tap opens it, kept across a reload', () => { seed(); setQuestionsOpen(true); expect(sections()).toEqual([['agent-007', '2', ''], ['general', '1', ''], ['finnamon', '3', '']]); expect(panel().querySelectorAll('.chat-questions-row')).toHaveLength(0); const urgent = (name) => !!header(name).querySelector('.chat-questions-urgent'); expect([urgent('agent-007'), urgent('general'), urgent('finnamon')]).toEqual([true, false, false]); // The "!" is hidden from screen readers; the header's name says it. expect(['agent-007', 'finnamon'].map(name => header(name).getAttribute('aria-label'))).toEqual(['agent-007, 2 open, blocking', 'finnamon, 3 open']); expect(header('agent-007').tagName).toBe('BUTTON'); // A heading wraps the button; the button controls the body, hidden while closed. expect(header('agent-007').parentElement.tagName).toBe('H3'); const body = () => document.getElementById(header('finnamon').getAttribute('aria-controls')); expect(body().hidden).toBe(true); expect(header('agent-007').getAttribute('aria-expanded')).toBe('false'); header('finnamon').click(); expect(header('finnamon').getAttribute('aria-expanded')).toBe('true'); expect(body().hidden).toBe(false); expect(document.activeElement).toBe(header('finnamon')); expect(sections()[2]).toEqual(['finnamon', '3', 'Q6 Q1 Q5']); expect(localStorage.getItem('agent007-questions-section:project:finnamon')).toBe('1'); // A refresh: the same sections open and closed. _reloadQuestionPrefs(); showWaiting(); expect(sections().map(g => g[2])).toEqual(['', '', 'Q6 Q1 Q5']); // Kept per grouping: by type, finance starts closed. setGroupBy('type'); expect(panel().querySelectorAll('.chat-questions-row')).toHaveLength(0); setGroupBy('project'); header('finnamon').click(); expect(sections()[2]).toEqual(['finnamon', '3', '']); expect(localStorage.getItem('agent007-questions-section:project:finnamon')).toBe('0'); }); }); describe('Telegram in the Billion tab', () => { beforeEach(() => { setWaitingActive(true); setTelegramState({ offers: [] }); }); it('names the group member who answered, and who wrote a Telegram message', () => { const q = { id: 'w1', n: 1, urgency: 'normal', status: 'answered', answer: 'Merge', answeredVia: 'telegram', answeredBy: 'Alice', answeredAt: new Date(0).toISOString() }; setChatMessages([ { id: 'm1', at: new Date().toISOString(), from: 'billion', text: 'Merge?', q }, { id: 'm2', at: new Date().toISOString(), from: 'owner', via: 'telegram', name: 'Alice', text: 'done' }, ]); renderWaiting(); expect(bubbleOf('w1').querySelector('.chat-answered').textContent).toBe('Alice answered: Merge (on Telegram)'); expect(document.querySelector('.chat-msg[data-id="m2"] .chat-meta').textContent).toMatch(/^Alice on Telegram · /); }); it('offers a chat with "Use this chat" and Dismiss, then says it is connected', () => { renderWaiting(); setTelegramState({ type: 'telegram-state', on: true, connected: false, offers: [{ chatId: '-5', name: 'Team' }] }); const bar = document.getElementById('chat-telegram'); expect(bar.hidden).toBe(false); expect(bar.textContent).toContain('Telegram: a message from Team (chat -5). Use it for Billion?'); [...bar.querySelectorAll('button')].find(b => b.textContent === 'Use this chat').click(); expect(send).toHaveBeenCalledWith({ type: 'telegram-use', chatId: '-5' }); [...bar.querySelectorAll('button')].find(b => b.textContent === 'Dismiss').click(); expect(send).toHaveBeenCalledWith({ type: 'telegram-dismiss', chatId: '-5' }); setTelegramState({ type: 'telegram-state', on: true, connected: true, connectedTo: 'Team', offers: [] }); expect(bar.textContent).toBe('Telegram connected: Team'); }); }); describe('Telegram in Settings', async () => { const { renderTelegramSettings } = await import('../public/modules/settings.js'); beforeEach(() => { document.body.innerHTML = ''; }); const box = () => document.getElementById('telegram-settings'); it('names the connected chat, and Change forgets it', () => { renderTelegramSettings({ on: true, connected: true, chat: { chatId: '-5', name: 'Team', fromEnv: false } }); expect(box().hidden).toBe(false); expect(box().querySelector('.settings-telegram').textContent).toBe('Connected to Team (chat -5).'); [...box().querySelectorAll('button')].find(b => b.textContent === 'Change').click(); expect(send).toHaveBeenCalledWith({ type: 'telegram-forget' }); }); it('has no Change for a chat set by TELEGRAM_CHAT_ID, and gives the steps when none is connected', () => { renderTelegramSettings({ on: true, connected: true, chat: { chatId: '42', name: '', fromEnv: true } }); expect(box().textContent).toContain('Connected to chat 42, set by TELEGRAM_CHAT_ID.'); expect(box().querySelector('button')).toBeNull(); renderTelegramSettings({ on: true, connected: false, offers: [] }); expect(box().textContent).toContain('Press "Use this chat" in the Billion tab'); }); });