Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
ca89072
MAESTRO: feat: add account multiplexing shared types and interfaces
openasocket Feb 15, 2026
39a4195
MAESTRO: feat: add account registry store and CRUD service
openasocket Feb 15, 2026
d3a9cf1
MAESTRO: feat: add account usage tracking to stats database (v4 migra…
openasocket Feb 15, 2026
e507795
MAESTRO: feat: add account multiplexing IPC handlers and preload bridge
openasocket Feb 15, 2026
769543a
MAESTRO: feat: add account directory setup service and login orchestr…
openasocket Feb 15, 2026
691d03b
MAESTRO: feat: add account management UI to Settings panel
openasocket Feb 15, 2026
e2097c2
MAESTRO: feat: add Account Usage Dashboard UI with lint and test fixes
openasocket Feb 15, 2026
d604b42
MAESTRO: feat: add throttle detection and account switch trigger
openasocket Feb 15, 2026
918acb9
MAESTRO: feat: add account switching execution with process restart a…
openasocket Feb 15, 2026
df40e23
MAESTRO: feat: add account switch confirmation modal and manual accou…
openasocket Feb 15, 2026
8c6bc00
MAESTRO: feat: integrate account multiplexing into all agent spawn paths
openasocket Feb 15, 2026
1143390
MAESTRO: feat: add session/assignment persistence and restart recover…
openasocket Feb 15, 2026
941ad85
MAESTRO: feat: add account multiplexing support to CLI batch runner
openasocket Feb 15, 2026
e33845c
MAESTRO: feat: add account propagation to session merge/transfer oper…
openasocket Feb 15, 2026
c89ee1d
MAESTRO: feat: add account display to ProcessMonitor, SymphonyModal, …
openasocket Feb 15, 2026
fef1a37
MAESTRO: test: add comprehensive test suite for AccountRecoveryPoller
openasocket Feb 15, 2026
7586956
MAESTRO: feat: add capacity-aware account selection to AccountRegistry
openasocket Feb 15, 2026
44e609a
MAESTRO: feat: wire statsDB into selectNextAccount callers for capaci…
openasocket Feb 15, 2026
6432d6e
MAESTRO: feat: add all-accounts-exhausted handling, recovery IPC, and…
openasocket Feb 15, 2026
b07d938
MAESTRO: feat: rename "Accounts" to "Virtuosos" across all UX surface…
openasocket Feb 15, 2026
ea5745c
MAESTRO: feat: add inline usage metrics to Virtuosos panel, AccountSe…
openasocket Feb 16, 2026
768141c
MAESTRO: feat: add usage analytics with P90 predictions, historical t…
openasocket Feb 16, 2026
7a55ed0
MAESTRO: feat: wire AccountSwitcher into main process and IPC handlers
openasocket Feb 16, 2026
ce9aafc
MAESTRO: feat: add auth recovery service, tests, and dedup getWindowB…
openasocket Feb 16, 2026
ce2dcbe
MAESTRO: refactor: code quality hardening for Virtuoso feature
openasocket Feb 16, 2026
f0d1565
MAESTRO: feat: add tabbed Configuration/Usage views to VirtuososModal
openasocket Feb 16, 2026
d254957
MAESTRO: feat: surface per-token-type breakdown in Virtuosos usage UI
openasocket Feb 16, 2026
f7b3115
MAESTRO: feat: add Trends & Analytics section with sparklines and rat…
openasocket Feb 16, 2026
df0488e
MAESTRO: fix: remove unused React import from AccountRateMetrics comp…
openasocket Feb 16, 2026
e5fadc3
MAESTRO: feat: add predictions, sparklines, and trend indicators to A…
openasocket Feb 16, 2026
f5172ef
MAESTRO: fix: resolve session ID mismatch in account usage/error list…
openasocket Feb 16, 2026
bc76b95
MAESTRO: feat: add time range toggles (24h/7d/30d/monthly) to Account…
openasocket Feb 16, 2026
bb4d94c
MAESTRO: fix: replace dynamic Tailwind classes with inline styles in …
openasocket Feb 16, 2026
6a3ef5e
MAESTRO: merge main into feat/virtuosos — resolve conflicts + migrate…
openasocket Feb 19, 2026
e7629a4
feat: gate Virtuosos behind Encore Features toggle
openasocket Feb 19, 2026
d84f5bc
MAESTRO: add session provenance fields and ProviderSwitchConfig type
openasocket Feb 19, 2026
8c8d575
MAESTRO: add useProviderSwitch hook and extend createMergedSession fo…
openasocket Feb 19, 2026
5d92c36
MAESTRO: add SwitchProviderModal component and PROVIDER_SWITCH priority
openasocket Feb 19, 2026
0d4002b
MAESTRO: add Switch Provider UI entry points to context menu and Edit…
openasocket Feb 19, 2026
8ac9259
MAESTRO: wire SwitchProviderModal state, hooks, and callbacks in App.tsx
openasocket Feb 19, 2026
bbe73cd
MAESTRO: add archive visual treatment for provider-switched sessions
openasocket Feb 19, 2026
4c1d9e2
MAESTRO: add Providers tab to VirtuososModal with status grid, failov…
openasocket Feb 19, 2026
a84dc93
MAESTRO: add automated provider failover with ProviderErrorTracker, I…
openasocket Feb 19, 2026
c2a8bd5
MAESTRO: add Provider Health Dashboard with live error monitoring and…
openasocket Feb 19, 2026
50360d8
MAESTRO: add merge-back mode for provider switching with provenance c…
openasocket Feb 19, 2026
1d0012c
MAESTRO: add time range selector and usage totals bar to Provider Hea…
openasocket Feb 19, 2026
ae2f7b3
MAESTRO: add Provider Detail View with navigation, summary stats, and…
openasocket Feb 19, 2026
c5a3b5b
MAESTRO: complete useProviderDetail hook with errorsByType, config th…
openasocket Feb 19, 2026
7dff7f9
MAESTRO: add Provider Detail Charts with 6 visualization panels
openasocket Feb 20, 2026
194323b
MAESTRO: add comparison benchmarks, clickable sessions, and migration…
openasocket Feb 20, 2026
d25db5b
MAESTRO: fix provider detail charts, add per-agent hourly stats, and …
openasocket Feb 20, 2026
acc91b5
MAESTRO: fix unarchive conflict detection and modal accessibility
openasocket Feb 20, 2026
29e087f
MAESTRO: multi-provider account support in Virtuosos
openasocket Feb 20, 2026
0838e34
MAESTRO: regenerate package-lock.json after rebase onto main
openasocket Mar 1, 2026
e91fcc3
MAESTRO: fix ThrottleEvent field mismatch between backend and frontend
openasocket Mar 1, 2026
40572ad
MAESTRO: fix SwitchProviderModal keyboard navigation by adding tabInd…
openasocket Mar 1, 2026
5efd412
MAESTRO: replace console.error and empty .catch with Sentry reporting…
openasocket Mar 1, 2026
62695d1
MAESTRO: remove unused archiveSource from ProviderSwitchRequest inter…
openasocket Mar 1, 2026
d7b588d
MAESTRO: report agent availability check failures to Sentry and fail …
openasocket Mar 1, 2026
c89b244
MAESTRO: add accountId fallback to SessionList tooltip
openasocket Mar 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 32 additions & 44 deletions package-lock.json

Large diffs are not rendered by default.

251 changes: 251 additions & 0 deletions src/__tests__/cli/services/account-reader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
/**
* @file account-reader.test.ts
* @description Tests for the CLI account reader service
*
* Tests reading account data from the electron-store JSON file,
* filesystem discovery fallback, and account lookup helpers.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import type { AccountProfile } from '../../../shared/account-types';

// Mock the fs module
vi.mock('fs', () => ({
readFileSync: vi.fn(),
promises: {
readdir: vi.fn(),
readFile: vi.fn(),
stat: vi.fn(),
},
}));

// Mock the os module
vi.mock('os', () => ({
platform: vi.fn(),
homedir: vi.fn(),
}));

import {
readAccountsFromStore,
getDefaultAccount,
getAccountByIdOrName,
} from '../../../cli/services/account-reader';

// Helper to build a mock AccountProfile
function mockProfile(overrides: Partial<AccountProfile> = {}): AccountProfile {
return {
id: 'acc-1',
name: 'personal',
email: 'user@example.com',
configDir: '/home/testuser/.claude-personal',
agentType: 'claude-code',
status: 'active',
authMethod: 'oauth',
addedAt: 1000,
lastUsedAt: 2000,
lastThrottledAt: 0,
tokenLimitPerWindow: 0,
tokenWindowMs: 18000000,
isDefault: true,
autoSwitchEnabled: false,
...overrides,
};
}

describe('account-reader', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.platform).mockReturnValue('linux');
vi.mocked(os.homedir).mockReturnValue('/home/testuser');
});

describe('readAccountsFromStore', () => {
it('reads accounts from the store JSON file', async () => {
const profile1 = mockProfile({ id: 'acc-1', name: 'personal', isDefault: true });
const profile2 = mockProfile({
id: 'acc-2',
name: 'work',
email: 'work@corp.com',
configDir: '/home/testuser/.claude-work',
isDefault: false,
});

const storeData = {
accounts: {
'acc-1': profile1,
'acc-2': profile2,
},
assignments: {},
switchConfig: {},
rotationOrder: [],
rotationIndex: 0,
};

vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData));

const accounts = await readAccountsFromStore();

expect(accounts).toHaveLength(2);
expect(accounts.find((a) => a.id === 'acc-1')).toMatchObject({
id: 'acc-1',
name: 'personal',
email: 'user@example.com',
isDefault: true,
status: 'active',
});
expect(accounts.find((a) => a.id === 'acc-2')).toMatchObject({
id: 'acc-2',
name: 'work',
email: 'work@corp.com',
isDefault: false,
});
});

it('returns empty array when store has no accounts', async () => {
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({ accounts: {}, assignments: {} })
);

const accounts = await readAccountsFromStore();
expect(accounts).toHaveLength(0);
});

it('falls back to filesystem discovery when store file missing', async () => {
vi.mocked(fs.readFileSync).mockImplementation(() => {
throw new Error('ENOENT');
});

vi.mocked(fs.promises.readdir).mockResolvedValue([
{ name: '.claude-personal', isDirectory: () => true } as unknown as fs.Dirent,
{ name: '.bashrc', isDirectory: () => false } as unknown as fs.Dirent,
{ name: 'Documents', isDirectory: () => true } as unknown as fs.Dirent,
]);

vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('ENOENT'));

const accounts = await readAccountsFromStore();
expect(accounts).toHaveLength(1);
expect(accounts[0]).toMatchObject({
id: 'personal',
name: 'personal',
configDir: '/home/testuser/.claude-personal',
status: 'active',
});
});

it('reads email from .claude.json during filesystem discovery', async () => {
vi.mocked(fs.readFileSync).mockImplementation(() => {
throw new Error('ENOENT');
});

vi.mocked(fs.promises.readdir).mockResolvedValue([
{ name: '.claude-work', isDirectory: () => true } as unknown as fs.Dirent,
]);

vi.mocked(fs.promises.readFile).mockResolvedValue(
JSON.stringify({ email: 'dev@company.com' })
);

const accounts = await readAccountsFromStore();
expect(accounts[0].email).toBe('dev@company.com');
});

it('handles macOS store path', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');

const storeData = {
accounts: { 'acc-1': mockProfile() },
assignments: {},
};
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData));

const accounts = await readAccountsFromStore();
expect(accounts).toHaveLength(1);

// Should try macOS path first
expect(fs.readFileSync).toHaveBeenCalledWith(
'/home/testuser/Library/Application Support/Maestro/maestro-accounts.json',
'utf-8'
);
});
});

describe('getDefaultAccount', () => {
it('returns the default active account', async () => {
const storeData = {
accounts: {
'acc-1': mockProfile({ id: 'acc-1', isDefault: false }),
'acc-2': mockProfile({ id: 'acc-2', isDefault: true }),
},
};
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData));

const account = await getDefaultAccount();
expect(account?.id).toBe('acc-2');
});

it('returns first active account when no default set', async () => {
const storeData = {
accounts: {
'acc-1': mockProfile({ id: 'acc-1', isDefault: false }),
},
};
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData));

const account = await getDefaultAccount();
expect(account?.id).toBe('acc-1');
});

it('skips throttled accounts when looking for default', async () => {
const storeData = {
accounts: {
'acc-1': mockProfile({ id: 'acc-1', isDefault: true, status: 'throttled' }),
'acc-2': mockProfile({ id: 'acc-2', isDefault: false, status: 'active' }),
},
};
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData));

const account = await getDefaultAccount();
expect(account?.id).toBe('acc-2');
});

it('returns null when no accounts exist', async () => {
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ accounts: {} }));

const account = await getDefaultAccount();
expect(account).toBeNull();
});
});

describe('getAccountByIdOrName', () => {
const storeData = {
accounts: {
'acc-1': mockProfile({ id: 'acc-1', name: 'personal' }),
'acc-2': mockProfile({ id: 'acc-2', name: 'work' }),
},
};

it('finds by ID', async () => {
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData));

const account = await getAccountByIdOrName('acc-2');
expect(account?.name).toBe('work');
});

it('finds by name', async () => {
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData));

const account = await getAccountByIdOrName('personal');
expect(account?.id).toBe('acc-1');
});

it('returns null when not found', async () => {
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData));

const account = await getAccountByIdOrName('nonexistent');
expect(account).toBeNull();
});
});
});
Loading