Ctrl K
ComunidadGuías…

test/billion-start.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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);
  });
});