test/client-waiting.test.js
Rama main · réplica de lectura del repositorio original.
// @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 = '<div id="terminal-tabs"></div><div id="terminal-empty"></div><div id="job-board"></div>'
+ '<div id="waiting-board" style="display:none"><div id="waiting-list"></div></div>'
+ '<nav class="mobile-nav"><button data-view="terminal"></button><button data-view="waiting"><span id="mobile-nav-waiting-count" hidden></span></button></nav>';
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', '<div id="status-bar"></div><div id="office-empty"></div>');
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: '<b>hi</b>' },
{ 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, '<b>hi</b>'], [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 = '<div id="terminal-empty"></div><div id="job-board"></div><div id="waiting-board" style="display:none"><div id="billion-bar"><div id="billion-subtabs"></div><div id="billion-status"></div></div><div id="waiting-list"></div></div>';
_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 += '<span id="voice-status"></span>';
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', '<button id="btn-voice"></button>');
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', '<button id="btn-voice"></button><div id="voice-indicator"><span class="voice-indicator-text"></span></div>');
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 = '<div id="telegram-settings" hidden></div>'; });
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');
});
});