test/billion-start.test.js
Rama main · réplica de lectura del repositorio original.
// startBillion (server.js) and the Start button's 'billion-start' message.
// createSessionFromConfig is replaced so no real Claude Code ever starts: what
// is under test is the folder, the command, and which session survives.
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import WebSocket from 'ws';
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
import { join, resolve } from 'path';
import { parseCommand } from '../lib/helpers.js';
import { tmpdir } from 'os';
import { execFileSync } from 'child_process';
const spawned = [];
let spawnError = null;
vi.mock('../server/pty.js', async (importOriginal) => ({
...(await importOriginal()),
createSessionFromConfig: vi.fn((cfg) => {
if (spawnError) return { error: spawnError };
const exits = [];
const session = {
...cfg, id: cfg.sessionId, state: 'WORKING', exited: false,
agent: cfg.command.startsWith('codex') ? 'codex' : cfg.command.startsWith('claude') ? 'claude' : null,
pty: { cols: 80, rows: 24, write: () => {}, onExit: (cb) => exits.push(cb), kill: () => { session.exited = true; exits.forEach(cb => cb({ exitCode: 0 })); } },
ringBuffer: { getAll: () => [] },
};
spawned.push(session);
return { session };
}),
}));
// Whether `claude` is on PATH, decided here rather than by the machine: CI has none.
let hasClaude = true;
vi.mock('../server/command-path.js', async (importOriginal) => ({
...(await importOriginal()),
commandExists: vi.fn(() => hasClaude),
}));
// Whether the CLI says it is logged in, decided here too: never a real `claude auth status`.
let loggedIn = true; // true, false, or null for a check that did not answer
vi.mock('../server/billion-limit.js', async (importOriginal) => ({
...(await importOriginal()),
cliReady: vi.fn(async () => { await new Promise(r => setTimeout(r, 0)); return loggedIn; }),
}));
// Discovery decided here too: never a real `codex debug models`.
let modelsIn = true;
vi.mock('../server/models.js', async (importOriginal) => ({
...(await importOriginal()),
modelsReady: vi.fn(async () => modelsIn),
availableModels: vi.fn(() => ({ claude: ['opus', 'haiku'], codex: ['gpt-x'] })),
startModelRefresh: vi.fn(),
}));
let transcript = { agent: null };
vi.mock('../server/agent-transcripts.js', async (importOriginal) => ({
...(await importOriginal()),
hasClaudeTranscript: vi.fn(() => transcript.agent === 'claude'),
codexSessionIdFor: vi.fn(() => (transcript.agent === 'codex' ? transcript.id : null)),
}));
// The handover reads transcripts under these homes, never the developer's own.
process.env.CLAUDE_CONFIG_DIR = mkdtempSync(join(tmpdir(), 'a007-bstart-claude-'));
process.env.CODEX_HOME = mkdtempSync(join(tmpdir(), 'a007-bstart-codex-'));
const { server, sessions, startBillion, switchBillion } = await import('../server.js');
const { config } = await import('../server/state.js');
const { billionAgentFile } = await import('../server/billion.js');
const { sendText, pendingMessages } = await import('../server/messages.js');
const freshDir = () => join(mkdtempSync(join(tmpdir(), 'a007-bstart-')), 'billion');
beforeEach(() => {
sessions.clear();
spawned.length = 0;
spawnError = null;
hasClaude = true;
loggedIn = true;
modelsIn = true;
transcript = { agent: null };
config.repos = [];
process.env.BILLION_DIR = freshDir();
delete process.env.BILLION;
delete process.env.BILLION_AGENT;
rmSync(billionAgentFile(), { force: true });
});
afterAll(() => { delete process.env.BILLION_DIR; delete process.env.BILLION; delete process.env.BILLION_AGENT; });
describe('startBillion', () => {
it('first run: makes its folder, introduces itself, and holds its mail', async () => {
config.repos = [{ path: '/p/projects/a' }, { path: '/p/projects/b' }];
const { session, error } = (await startBillion());
expect(error).toBeUndefined();
expect(session.isBillion).toBe(true);
expect(session.name).toBe('Billion');
expect(session.cwd).toBe(process.env.BILLION_DIR);
expect(session.repoPath).toBeNull();
expect(session.messagesHeld).toBe(true);
expect(session.command).toMatch(/first run/);
expect(session.command).not.toMatch(/--continue/);
// Read back through parseCommand: on Windows the path has a drive and
// backslashes, which the command string escapes.
expect(parseCommand(session.command).args.at(-1)).toContain(`Suggest ${resolve('/p/projects')} as the projects folder`);
expect(sessions.get(session.id)).toBe(session);
});
it('returns the running one instead of starting a second', async () => {
const first = (await startBillion()).session;
expect((await startBillion())).toMatchObject({ session: first, existing: true });
expect(spawned).toHaveLength(1);
});
it('replaces a stopped one, continuing its conversation and refreshing an old charter', async () => {
const old = (await startBillion()).session;
old.exited = true;
const dir = process.env.BILLION_DIR;
writeFileSync(join(dir, 'CHARTER.md'), 'an older charter');
execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qam', 'old'], { cwd: dir });
transcript = { agent: 'claude' };
const { session } = (await startBillion());
expect(session).not.toBe(old);
expect(sessions.has(old.id)).toBe(false);
expect([...sessions.values()].filter(s => s.isBillion)).toEqual([session]);
expect(session.command).toMatch(/--continue/);
expect(session.command).toMatch(/You were restarted/);
expect(readFileSync(join(dir, 'CHARTER.md'), 'utf8')).not.toBe('an older charter');
});
it('names its CLI and the models in the prompt, or where to read them when discovery is late', async () => {
const prompt = parseCommand((await startBillion()).session.command).args.at(-1);
expect(prompt).toContain('You run on Claude Code; a card you post without `agent` goes to the same CLI.');
expect(prompt).toContain('Models available now: claude: opus, haiku; codex: gpt-x.');
(await startBillion()).session.exited = true;
modelsIn = false;
const late = parseCommand((await startBillion()).session.command).args.at(-1);
expect(late).toMatch(/model list is not ready yet; read it in .*billion-tools\.json/);
// The file it points to carries the lists, and says which CLI Billion is.
const saved = JSON.parse(readFileSync(late.match(/read it in (\S+billion-tools\.json)/)[1], 'utf8'));
const post = saved.find(t => t.name === 'post_job').inputSchema.properties;
expect(post.model.description).toContain('codex: gpt-x');
expect(post.agent.description).toContain('You are running as claude.');
});
it('does not continue a Codex transcript', async () => {
(await startBillion()).session.exited = true;
transcript = { agent: 'codex' };
expect((await startBillion()).session.command).not.toMatch(/--continue/);
});
it('reports a folder it cannot make, and starts nothing', async () => {
const file = join(mkdtempSync(join(tmpdir(), 'a007-bstart-file-')), 'plain');
writeFileSync(file, 'not a folder');
process.env.BILLION_DIR = join(file, 'billion');
const { error } = (await startBillion());
expect(error).toMatch(/Could not set up Billion's folder/);
expect(spawned).toHaveLength(0);
expect(sessions.size).toBe(0);
});
it('without claude, its tab says how to install it or turn Billion off', async () => {
hasClaude = false;
const { session, notice } = (await startBillion());
expect(notice).toMatch(/not installed/);
expect(session.isBillion).toBe(true);
const { file, args } = parseCommand(session.command);
expect(file).toBe(process.execPath);
const out = execFileSync(file, args, { encoding: 'utf8' });
expect(out).toMatch(/Install it from https:\/\/docs\.anthropic\.com/);
expect(out).toMatch(/BILLION=0/);
// Its chat tab says so too: the terminal is gone a second later.
expect(session.notice).toMatch(/Billion runs on Claude Code, which is not installed/);
// Start checks again: once claude is there, the real Billion starts.
session.exited = true;
hasClaude = true;
expect((await startBillion()).session.command).toMatch(/^claude /);
});
it('passes a spawn failure on, leaving no session behind', async () => {
spawnError = 'claude: command not found';
expect((await startBillion())).toEqual({ error: 'claude: command not found' });
expect(sessions.size).toBe(0);
});
});
describe('Billion on Codex', () => {
it('starts codex with its instructions in AGENTS.md, which git ignores', async () => {
process.env.BILLION_AGENT = 'codex';
const { session } = (await startBillion());
expect(session.command).toMatch(/^codex --dangerously-bypass-approvals-and-sandbox /);
expect(session.command).toMatch(/first run/);
const dir = process.env.BILLION_DIR;
const agents = readFileSync(join(dir, 'AGENTS.md'), 'utf8');
expect(agents).toContain("# Billion's charter");
expect(agents).toContain("## Owner's rules");
expect(agents).not.toMatch(/^@CHARTER\.md/m);
expect(execFileSync('git', ['status', '--porcelain'], { cwd: dir, encoding: 'utf8' })).toBe('');
});
it('resumes its own Codex session in the folder by id after a restart', async () => {
process.env.BILLION_AGENT = 'codex';
(await startBillion()).session.exited = true;
transcript = { agent: 'codex', id: '019a0000-0000-7000-8000-000000000001' };
expect((await startBillion()).session.command).toMatch(/^codex resume 019a0000-0000-7000-8000-000000000001 --dangerously-bypass-approvals-and-sandbox "You were restarted/);
});
it('without codex, its tab says how to install it', async () => {
process.env.BILLION_AGENT = 'codex';
hasClaude = false;
const { session, notice } = (await startBillion());
expect(notice).toMatch(/Codex \(codex\) is not installed/);
const { file, args } = parseCommand(session.command);
expect(execFileSync(file, args, { encoding: 'utf8' })).toMatch(/Billion runs on Codex/);
});
});
describe('a logged-out CLI', () => {
it('puts a notice on Billion saying to sign in in its terminal; clearing it tells the browsers once', async () => {
loggedIn = false;
const { session, notice } = (await startBillion());
expect(notice).toBeUndefined();
await vi.waitFor(() => expect(session.notice).toMatch(/Claude Code says it is not logged in.*Open Billion's terminal/));
const { setBillionNotice } = await import('../server/billion.js');
const sent = [];
setBillionNotice(session, null, (m) => sent.push(m));
expect(session.notice).toBeNull();
expect(sent).toEqual([{ type: 'billion-notice', sessionId: session.id, notice: null }]);
// Unchanged: nothing sent.
setBillionNotice(session, null, (m) => sent.push(m));
expect(sent).toHaveLength(1);
});
it('leaves a logged-in one alone, and one whose check did not answer', async () => {
for (const answer of [true, null]) {
loggedIn = answer;
sessions.clear();
const { session } = (await startBillion());
await new Promise(r => setTimeout(r, 10));
expect(session.notice).toBeUndefined();
}
});
it('says nothing when the answer comes after Billion is ready or gone', async () => {
loggedIn = false;
const ready = (await startBillion()).session;
ready.messagesHeld = false;
sessions.clear();
const gone = (await startBillion()).session;
gone.exited = true;
await new Promise(r => setTimeout(r, 10));
expect(ready.notice).toBeUndefined();
expect(gone.notice).toBeUndefined();
});
it('drops the sign-in notice when that Billion exits', async () => {
loggedIn = false;
const { session } = (await startBillion());
await vi.waitFor(() => expect(session.notice).toMatch(/not logged in/));
session.pty.kill();
expect(session.notice).toBeNull();
});
});
describe('switchBillion', () => {
it('writes the handover, stops the old one, saves the choice and starts the other fresh, mail and all', async () => {
const old = (await startBillion()).session;
old.messagesHeld = false;
old.state = 'WORKING'; // so the notice waits in its queue
sendText(old, '[Job board] a notice');
transcript = { agent: 'claude' };
const { session, error } = await switchBillion();
expect(error).toBeUndefined();
expect(old.exited).toBe(true);
expect(session.command).toMatch(/^codex --dangerously-bypass-approvals-and-sandbox /);
expect(session.command).toMatch(/HANDOVER\.md/);
expect(session.command).not.toMatch(/ resume /);
expect(existsSync(join(process.env.BILLION_DIR, 'HANDOVER.md'))).toBe(true);
expect(JSON.parse(readFileSync(billionAgentFile(), 'utf8')).agent).toBe('codex');
expect([...sessions.values()].filter(s => s.isBillion && !s.exited)).toEqual([session]);
expect(pendingMessages(session.id)).toBe(1);
expect(session.messagesHeld).toBe(true);
// The saved choice outlives a restart; back again starts claude fresh.
session.exited = true;
expect((await startBillion()).session.command).toMatch(/^codex /);
const back = await switchBillion('claude');
expect(back.session.command).toMatch(/^claude --dangerously-skip-permissions "You now run on Claude Code/);
expect(back.session.command).not.toMatch(/--continue/);
});
it('refuses a switch to the CLI it already runs on, and an unknown one', async () => {
(await startBillion());
expect((await switchBillion('claude')).error).toMatch(/already runs on Claude Code/);
expect((await switchBillion('gemini')).error).toMatch(/not gemini/);
expect(spawned).toHaveLength(1);
});
});
describe('the Start button (billion-start)', () => {
let url;
beforeAll(async () => {
server.listen(0, '127.0.0.1');
await new Promise(r => server.once('listening', r));
url = `ws://127.0.0.1:${server.address().port}`;
});
// A socket a failed test left open would hold server.close for the whole
// hook timeout, so every socket goes here and is cut after each test.
const sockets = [];
afterEach(() => { sockets.splice(0).forEach(ws => ws.terminate()); });
afterAll(() => new Promise(r => server.close(r)));
const open = () => new Promise((resolve, reject) => {
const ws = new WebSocket(url);
sockets.push(ws);
const seen = [];
ws.on('message', (d) => seen.push(JSON.parse(d)));
// The first message of that type, however long it takes: startBillion runs
// git synchronously, over vi.waitFor's 1 s on a slow Windows runner.
const next = (type) => new Promise(r => {
const check = () => { const m = seen.find(m => m.type === type); if (m) { ws.off('message', check); r(m); } };
ws.on('message', check);
check();
});
ws.on('open', () => resolve({ ws, seen, next }));
ws.on('error', reject);
});
const settle = () => new Promise(r => setTimeout(r, 200));
it('starts Billion and focuses it in the window that asked', async () => {
const { ws, next } = await open();
ws.send(JSON.stringify({ type: 'billion-start' }));
expect(await next('session-created')).toMatchObject({ isBillion: true, focus: true, name: 'Billion' });
});
it('shows the error when Billion cannot start', async () => {
spawnError = 'claude: command not found';
const { ws, next } = await open();
ws.send(JSON.stringify({ type: 'billion-start' }));
expect((await next('spawn-error')).error).toBe('claude: command not found');
});
it('announces Billion once, however often Start is pressed', async () => {
const { ws, seen, next } = await open();
ws.send(JSON.stringify({ type: 'billion-start' }));
await next('session-created');
ws.send(JSON.stringify({ type: 'billion-start' }));
await settle();
expect(seen.filter(m => m.type === 'session-created' && m.isBillion)).toHaveLength(1);
});
it('says why when Billion is turned off, and starts nothing', async () => {
process.env.BILLION = '0';
const { ws, seen, next } = await open();
expect((await next('welcome')).billionEnabled).toBe(false);
ws.send(JSON.stringify({ type: 'billion-start' }));
expect((await next('spawn-error')).error).toMatch(/Billion is off/);
expect(spawned).toHaveLength(0);
expect(seen.some(m => m.type === 'session-created')).toBe(false);
});
});