// @vitest-environment happy-dom
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../public/modules/ws.js', () => ({ send: vi.fn(() => true) }));
import { send } from '../public/modules/ws.js';
import { setBillionEnabled, setSelf } from '../public/modules/state.js';
import { handleAccountState, handleAccountError, renderAccount, setScan, scanLogins } from '../public/modules/account.js';
const click = action => document.querySelector(`[data-action="${action}"]`).click();
const account = (id, email, status = 'Available') => ({ id, email, enabled: true, status });
const show = rotation => handleAccountState({ type: 'account-state', rotation });
beforeEach(() => {
document.body.innerHTML = '
';
setBillionEnabled(true); setSelf(null, false); setScan(null); send.mockReset(); send.mockReturnValue(true);
window.confirm = vi.fn(() => true);
handleAccountError({ message: null });
// Unsaved edits live in the module; an interrupted switch always drops them.
handleAccountState({ type: 'account-state', rotation: { pending: true, accounts: [] }, codexRotation: { pending: true, accounts: [] } });
});
describe('account rotation settings', () => {
it('keeps the folder available to correct after an error and emphasizes recovery errors', () => {
const rotation = { enabled: false, accounts: [{ ...account('a', 'a@x'), error: 'Sign in again' }] };
show(rotation);
const input = document.querySelector('#account-config-folder');
input.value = '/incorrect/folder'; input.dispatchEvent(new Event('input'));
handleAccountError({ message: 'Folder not found' });
expect(document.querySelector('#account-config-folder').value).toBe('/incorrect/folder');
expect([...document.querySelectorAll('.account-error')].some(el => el.textContent === 'Sign in again')).toBe(true);
show({ pending: true, accounts: [] });
expect(document.querySelector('.account-error').textContent).toContain('switch was interrupted');
show(rotation); document.querySelector('#account-config-folder').value = ''; document.querySelector('#account-config-folder').dispatchEvent(new Event('input'));
});
it('restores controls and reports a disconnected socket so the owner can retry', () => {
show({ enabled: false, accounts: [] });
const add = () => { document.querySelector('#account-config-folder').value = '/x'; click('rotation-add'); };
send.mockReturnValue(false);
add();
expect(document.querySelector('.account-error').textContent).toContain('Not connected');
expect(document.querySelector('[data-action="rotation-add"]').disabled).toBe(false);
send.mockReturnValue(true);
add();
expect(send).toHaveBeenCalledTimes(2);
expect(document.querySelector('[data-action="rotation-add"]').disabled).toBe(true);
});
it('shows the disabled auto-switch control before two accounts are found', () => {
show({ enabled: false, defaultSettings: true, fallback: true, accounts: [] });
expect(document.body.textContent).toContain('Two logged-in Claude or Codex accounts turn auto-switching on');
expect(document.querySelector('#account-auto-switch').disabled).toBe(true);
expect(document.querySelector('.account-manual').open).toBe(false);
expect(send).not.toHaveBeenCalled();
});
it('directs a single-account setup to logging in another rather than selecting a missing account', () => {
show({ enabled: false, defaultSettings: false, accounts: [account('a', 'a@x')] });
expect(document.querySelector('#account-auto-switch').disabled).toBe(true);
expect(document.querySelector('#account-auto-switch-hint').textContent).toContain('Log in to a second Claude or Codex account, then Refresh');
});
it('offers custom folders without enabling rotation, and no Find button', () => {
show({ enabled: false, fallback: true, accounts: [] });
expect(document.querySelector('[data-action="rotation-discover"]')).toBeNull();
expect(document.querySelector('#account-auto-switch').checked).toBe(false);
click('rotation-add'); expect(send).not.toHaveBeenCalled();
document.querySelector('#account-config-folder').value = ' ~/.claude-work ';
click('rotation-add'); expect(send).toHaveBeenCalledWith({ type: 'account', action: 'rotation-add', folder: '~/.claude-work' });
expect(window.confirm).not.toHaveBeenCalled();
expect([...document.querySelectorAll('button')].every(b => b.disabled)).toBe(true);
});
it('saves order and inclusion together, confirms enabling once', () => {
show({ enabled: false, active: 'a', accounts: [account('a', 'a@x', 'Active'), account('b', 'b@x'), account('c', 'c@x')] });
document.querySelectorAll('[data-action="rotation-up"]')[2].click();
const boxes = document.querySelectorAll('input[type="checkbox"]');
expect(boxes).toHaveLength(4);
boxes[3].click(); boxes[0].click();
click('rotation-configure');
expect(send).toHaveBeenCalledWith({ type: 'account', action: 'rotation-configure', enabled: true, accounts: [{ id: 'a', enabled: true }, { id: 'c', enabled: true }, { id: 'b', enabled: false }] });
expect(window.confirm).toHaveBeenCalledTimes(1);
});
it('prevents enabling or saving auto-switch with fewer than two selected accounts but allows turning it off', () => {
show({ enabled: true, active: 'a', accounts: [account('a', 'a@x'), account('b', 'b@x')] });
document.querySelectorAll('.rotation-account input')[1].click();
expect(document.querySelector('[data-action="rotation-configure"]').disabled).toBe(true);
const auto = document.querySelector('#account-auto-switch');
expect(auto.disabled).toBe(false);
auto.click();
expect(auto.disabled).toBe(true);
click('rotation-configure');
expect(send).toHaveBeenCalledWith(expect.objectContaining({ enabled: false }));
});
it('unlocks automatic switching when a second account is selected and blocks duplicate saves', () => {
show({ enabled: false, accounts: [account('a', 'a@x'), { ...account('b', 'b@x'), enabled: false }] });
const auto = document.querySelector('#account-auto-switch');
expect(auto.disabled).toBe(true);
expect(document.getElementById(auto.getAttribute('aria-describedby')).textContent).toContain('Select at least two');
document.querySelectorAll('.rotation-account input')[1].click();
expect(auto.disabled).toBe(false);
auto.click();
click('rotation-configure'); click('rotation-configure');
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith(expect.objectContaining({ enabled: true }));
});
it('keeps the draft editable when enabling automatic switching is cancelled', () => {
show({ enabled: false, accounts: [account('a', 'a@x'), account('b', 'b@x')] });
document.querySelector('#account-auto-switch').click();
window.confirm.mockReturnValue(false);
click('rotation-configure');
expect(send).not.toHaveBeenCalled();
expect(document.querySelector('[data-action="rotation-configure"]').disabled).toBe(false);
expect(document.querySelector('#account-auto-switch').checked).toBe(true);
});
it('keeps manual folder entry expanded after an error and collapsed after closing it', () => {
const rotation = { enabled: false, accounts: [] };
show(rotation);
const manual = document.querySelector('.account-manual');
manual.open = true; manual.dispatchEvent(new Event('toggle'));
const input = document.querySelector('#account-config-folder');
input.value = '/missing/account'; input.dispatchEvent(new Event('input'));
click('rotation-add');
handleAccountError({ message: 'Folder not found' });
expect(document.querySelector('.account-manual').open).toBe(true);
expect(document.querySelector('#account-config-folder').value).toBe('/missing/account');
const restored = document.querySelector('.account-manual');
restored.open = false; restored.dispatchEvent(new Event('toggle'));
show(rotation);
expect(document.querySelector('.account-manual').open).toBe(false);
document.querySelector('#account-config-folder').value = '';
document.querySelector('#account-config-folder').dispatchEvent(new Event('input'));
});
it('switches by account id, renders emails as text, and keeps errors visible after state updates', () => {
const rotation = { enabled: true, active: 'a', accounts: [account('a', 'a@x'), account('b', '
')] };
show(rotation); expect(document.querySelector('img')).toBeNull();
window.confirm.mockReturnValue(false); click('rotation-switch'); expect(send).not.toHaveBeenCalled();
window.confirm.mockReturnValue(true); click('rotation-switch');
expect(send).toHaveBeenCalledWith({ type: 'account', action: 'rotation-switch', id: 'b' });
handleAccountError({ message: 'Another Claude process is running.' }); show(rotation);
expect(document.querySelector('.account-error').textContent).toContain('Another Claude process');
});
it('offers recovery after an interrupted switch and hides when owner actions are unavailable', () => {
show({ pending: true, accounts: [] });
expect(document.querySelector('#account-auto-switch')).toBeNull();
click('rotation-recover'); expect(send).toHaveBeenCalledWith({ type: 'account', action: 'rotation-recover' });
const noControls = () => document.querySelectorAll('.account-body button, .account-body input').length === 0;
setSelf('u1', true); renderAccount(); expect(noControls()).toBe(true);
setSelf(null, false); setBillionEnabled(false); renderAccount(); expect(noControls()).toBe(true);
});
// Value: protects=an old-version switch left 'switching' still offers Restore login from previous version, sending rollback after confirm;
// fails_when=the rollback button drops its status check, skips confirm, or sends a cli/other action;
// why_new=no client test covered the legacy rollback button; seam=none
it('offers the previous-version restore for an interrupted old switch, after confirm', () => {
handleAccountState({ type: 'account-state', status: 'switching', rotation: { enabled: false, accounts: [] }, codexRotation: { enabled: false, accounts: [] } });
expect(document.querySelectorAll('[data-action="rollback"]')).toHaveLength(1);
const restore = document.querySelector('[data-action="rollback"]');
expect(restore.textContent).toBe('Restore login from previous version');
window.confirm.mockReturnValue(false); restore.click(); expect(send).not.toHaveBeenCalled();
window.confirm.mockReturnValue(true); restore.click();
expect(send).toHaveBeenCalledWith({ type: 'account', action: 'rollback' });
handleAccountState({ type: 'account-state', status: 'idle', rotation: { enabled: false, accounts: [] } });
expect(document.querySelector('[data-action="rollback"]')).toBeNull();
});
});
describe('one list of Claude and Codex accounts', () => {
const both = (rotation, codexRotation) => handleAccountState({ type: 'account-state', rotation, codexRotation });
const emails = () => [...document.querySelectorAll('.rotation-account label span:not(.account-cli-tag)')].map(s => s.textContent);
const tags = () => [...document.querySelectorAll('.account-cli-tag')].map(s => s.textContent);
const claude = { enabled: true, active: 'a', accounts: [account('a', 'a@x', 'Active'), account('b', 'b@x')] };
const codexState = { enabled: true, active: 'c', accounts: [account('c', 'c@x', 'Active'), account('d', 'd@x')] };
// Value: protects=old per-CLI settings (no rank) show Claude first then Codex, each in saved order, and saved ranks order the mixed list;
// fails_when=the merge drops a CLI, sorts unstable, or ignores rank; why_new=one list replaced two sections; seam=none
it('lists Claude accounts then Codex, each tagged, until a saved order mixes them', () => {
both(claude, codexState);
expect(emails()).toEqual(['a@x', 'b@x', 'c@x', 'd@x']);
expect(tags()).toEqual(['Claude', 'Claude', 'Codex', 'Codex']);
expect(document.querySelectorAll('#account-auto-switch')).toHaveLength(1);
expect(document.querySelectorAll('[data-action="rotation-configure"]')).toHaveLength(1);
expect(document.body.textContent).not.toContain('Fall back');
expect(document.body.textContent).toContain('other tools that read it follow it');
const ranked = (r, ranks) => ({ ...r, accounts: r.accounts.map((a, i) => ({ ...a, rank: ranks[i] })) });
both(ranked(claude, [0, 2]), ranked(codexState, [1, 3]));
expect(emails()).toEqual(['a@x', 'c@x', 'b@x', 'd@x']);
});
it('saves one mixed order for both CLIs, with no cli and no fallback', () => {
both(claude, codexState);
document.querySelectorAll('[data-action="rotation-up"]')[2].click(); // c above b
click('rotation-configure');
expect(send).toHaveBeenCalledWith({ type: 'account', action: 'rotation-configure', enabled: true,
accounts: ['a', 'c', 'b', 'd'].map(id => ({ id, enabled: true })) });
expect(window.confirm).not.toHaveBeenCalled();
});
it('counts selected accounts across both CLIs', () => {
both({ enabled: false, active: 'a', accounts: [account('a', 'a@x', 'Active')] }, { enabled: false, active: 'c', accounts: [account('c', 'c@x', 'Active')] });
expect(document.querySelector('#account-auto-switch').disabled).toBe(false);
});
it('keeps each CLI\'s own active account and sends cli: codex for a Codex row', () => {
both(claude, codexState);
const switches = [...document.querySelectorAll('[data-action="rotation-switch"]')];
expect(switches.map(b => b.closest('.rotation-account').dataset.cli)).toEqual(['claude', 'codex']);
switches[1].click();
expect(send).toHaveBeenCalledWith({ type: 'account', action: 'rotation-switch', cli: 'codex', id: 'd' });
expect(switches[1].getAttribute('aria-label')).toBe('Switch Codex to d@x now');
});
it('puts compact arrow buttons in the account row, disabled at the ends', () => {
both(claude, codexState);
const ups = [...document.querySelectorAll('[data-action="rotation-up"]')], downs = [...document.querySelectorAll('[data-action="rotation-down"]')];
expect(ups.map(b => [b.textContent, b.title])).toEqual(ups.map(() => ['↑', 'Move up']));
expect(downs.map(b => [b.textContent, b.title])).toEqual(downs.map(() => ['↓', 'Move down']));
expect(ups[0].getAttribute('aria-label')).toBe('Move Claude account a@x up');
expect(ups.map(b => b.disabled)).toEqual([true, false, false, false]);
expect(downs.map(b => b.disabled)).toEqual([false, false, false, true]);
expect(ups[0].closest('.rotation-account').querySelector('.account-actions')).not.toBeNull();
expect(document.querySelector('.account-email').title).toBe('a@x');
});
it('keeps unsaved edits when a state update arrives, and drops them once saved', () => {
both(claude, codexState);
document.querySelectorAll('[data-action="rotation-down"]')[1].click(); // b below c
both(claude, codexState);
expect(emails()).toEqual(['a@x', 'c@x', 'b@x', 'd@x']);
click('rotation-configure');
handleAccountError({ message: 'Invalid rotation settings.' });
both(claude, codexState);
expect(emails()).toEqual(['a@x', 'c@x', 'b@x', 'd@x']);
const rank = (r, ranks) => ({ ...r, accounts: r.accounts.map((a, i) => ({ ...a, rank: ranks[i] })) });
both(rank(claude, [0, 2]), rank(codexState, [1, 3]));
both(claude, codexState);
expect(emails()).toEqual(['a@x', 'b@x', 'c@x', 'd@x']);
});
it('keeps unsaved edits through a lasting registry error, and drops them when the account list changes', () => {
both(claude, codexState);
document.querySelectorAll('[data-action="rotation-down"]')[0].click();
both(claude, { ...codexState, error: 'The selected login failed; the previous login was restored.' });
expect(emails()).toEqual(['b@x', 'a@x', 'c@x', 'd@x']);
expect(document.querySelector('.account-error').textContent).toContain('selected login failed');
both(claude, { ...codexState, accounts: [...codexState.accounts, account('e', 'e@x')] });
expect(emails()).toEqual(['a@x', 'b@x', 'c@x', 'd@x', 'e@x']);
});
it('holds the list for an interrupted Codex switch and offers Restore previous Codex login', () => {
both(claude, codexState);
document.querySelectorAll('[data-action="rotation-down"]')[0].click();
both(claude, { ...codexState, pending: true });
expect(document.querySelector('#account-auto-switch')).toBeNull();
expect(document.querySelector('.account-error').textContent).toContain('Codex account switch was interrupted');
click('rotation-recover');
expect(send).toHaveBeenCalledWith({ type: 'account', action: 'rotation-recover', cli: 'codex' });
});
// Value: protects=paused Codex conversations get their own Retry button that resumes Codex, not Claude;
// fails_when=the resume button drops cli: codex, or shows for a CLI with nothing paused;
// why_new=no client test clicked rotation-resume after the two sections became one list; seam=none
it('offers Retry paused conversations only for the CLI that has them, sending its cli', () => {
both(claude, { ...codexState, resumePending: true });
const resume = [...document.querySelectorAll('[data-action="rotation-resume"]')];
expect(resume.map(b => b.textContent)).toEqual(['Retry paused Codex conversations']);
resume[0].click();
expect(send).toHaveBeenCalledWith({ type: 'account', action: 'rotation-resume', cli: 'codex' });
});
it('adds a folder for the CLI picked next to it', () => {
both({ enabled: false, accounts: [] }, { enabled: false, accounts: [] });
const kind = document.querySelector('#account-folder-cli');
kind.value = 'codex'; kind.dispatchEvent(new Event('change'));
const input = document.querySelector('#account-config-folder');
expect(input.placeholder).toBe('~/.codex-work');
input.value = '~/.codex-work'; click('rotation-add');
expect(send).toHaveBeenCalledWith({ type: 'account', action: 'rotation-add', cli: 'codex', folder: '~/.codex-work' });
kind.value = 'claude'; kind.dispatchEvent(new Event('change'));
});
});
describe('one Accounts list: the scan\'s logins with the switch list', () => {
const H = '/h';
const agents = [
{ cli: 'claude', version: '1', path: '/bin/claude', accounts: [
{ folder: `${H}/.claude`, isDefault: true, email: 'a@x', plan: 'max', org: null, loggedIn: true },
{ folder: `${H}/.claude-a`, isDefault: false, email: 'A@x', plan: null, org: null, loggedIn: true },
{ folder: `${H}/.claude-old`, isDefault: false, email: null, plan: null, org: null, loggedIn: false } ] },
{ cli: 'gemini', version: '1', path: '/bin/gemini', accounts: [
{ folder: `${H}/.gemini`, isDefault: true, email: 'g@x', plan: null, org: null, loggedIn: true } ] },
{ cli: 'aider', version: '1', path: '/bin/aider', accounts: [] },
];
const rows = () => [...document.querySelectorAll('.rotation-account')].map(r => ({
cli: r.dataset.cli, name: r.querySelector('.account-identity :is(label, .account-name) > span:not(.account-cli-tag)').textContent,
box: !!r.querySelector('input'), meta: r.querySelector('.account-meta')?.textContent }));
// Value: protects=one row per login: folders of one email under one CLI merge, a folder with no email is its own row;
// fails_when=the key ignores the CLI or email case, or a merge loses default/plan/logged-in; why_new=the scan's folders became login rows; seam=none
it('makes one login of folders logged in as one email under one CLI', () => {
const logins = scanLogins(agents);
expect(logins.map(l => l.key)).toEqual(['claude:a@x', `claude:${H}/.claude-old`, 'gemini:g@x']);
expect(logins[0]).toMatchObject({ isDefault: true, plan: 'max', loggedIn: true });
});
// Value: protects=registry accounts keep their switch controls and gain the scan's plan/default, and every other login shows read-only after them;
// fails_when=a scan login duplicates a registry row, Gemini gets a checkbox, or a logged-out folder disappears;
// why_new=Agents & accounts and Auto-switch accounts became one list; seam=setScan
it('shows switch rows in order, then the other logins read-only, each tagged', () => {
setScan(agents);
handleAccountState({ type: 'account-state', rotation: { enabled: false, active: 'a', accounts: [account('a', 'a@x', 'Active')] } });
expect(rows()).toEqual([
{ cli: 'claude', name: 'a@x', box: true, meta: 'maxActive' },
{ cli: 'claude', name: '~/.claude-old', box: false, meta: 'Logged out' },
{ cli: 'gemini', name: 'g@x', box: false, meta: 'Logged inDefault' },
]);
expect([...document.querySelectorAll('.account-cli-tag')].map(t => t.textContent)).toEqual(['Claude', 'Claude', 'Gemini']);
// One status: Logged out wins over the switch status, and Default shows unless that login is Active.
handleAccountState({ type: 'account-state', rotation: { enabled: false, active: 'b', accounts: [{ ...account('a', 'a@x', 'Needs login'), error: 'Sign in again' }, account('b', 'b@x', 'Active')] } });
expect(rows()[0].meta).toBe('maxLogged outDefault');
handleAccountState({ type: 'account-state', rotation: { enabled: false, active: 'a', accounts: [account('a', 'a@x', 'Active')] } });
// The CLI leads each row, inside the checkbox label for a switch row.
expect(document.querySelector('.rotation-account .rotation-toggle').textContent).toBe('Claudea@x');
expect(document.querySelectorAll('[data-action="rotation-configure"]')).toHaveLength(1);
});
// Value: protects=with user accounts on the logins still show, with no switch controls at all;
// fails_when=the read-only path is hidden, or leaks a checkbox, Save or folder field; why_new=the read-only list moved here; seam=setSelf
it('lists every login read-only while user accounts are on', () => {
setSelf('u1', true);
setScan(agents);
expect(rows().map(r => [r.name, r.box])).toEqual([['a@x', false], ['~/.claude-old', false], ['g@x', false]]);
expect(document.querySelectorAll('.account-body button, .account-body input, .account-body select')).toHaveLength(0);
});
it('marks only the Active login of the CLI Billion runs on, and follows it', () => {
const both = billionCli => handleAccountState({ type: 'account-state', billionCli,
rotation: { enabled: false, active: 'a', accounts: [account('a', 'a@x', 'Active')] },
codexRotation: { enabled: false, active: 'c', accounts: [{ ...account('c', 'c@x', 'Active'), cli: 'codex' }] } });
const metas = () => [...document.querySelectorAll('.rotation-account')].map(r => r.querySelector('.account-meta')?.textContent);
both('claude');
expect(metas()).toEqual(['ActiveBillion', 'Active']);
expect(document.querySelector('[title="Billion runs on this login"]').closest('.rotation-account').dataset.cli).toBe('claude');
both('codex');
expect(metas()).toEqual(['Active', 'ActiveBillion']);
both(null);
expect(metas()).toEqual(['Active', 'Active']);
});
});