Ctrl K
ComunidadGuías…

test/doctor.test.js

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

Archivo original ↗Preguntar a KiBot
import { describe, it, expect, vi, afterEach } from 'vitest';
import { createServer as createHttpServer } from 'http';
import { createServer } from 'net';
import { join, posix, win32 } from 'path';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync } from 'fs';
import { tmpdir } from 'os';
import { telegramGetMe } from '../server/owner.js';
import {
  portState, runDoctor, failed, formatReport, formatStartup, formatSection, formatSummary, useColor, SECTIONS, versionAtLeast,
  insideDir, checkNode, checkClis, checkGh, checkRepos, checkPort, checkSettings, checkVersion, checkTelegram, checkWhisper, checkPlugins, checkSkills, checkService, checkRemote, checkOrphans, toNpm, fromNpm,
} from '../server/doctor.js';
import { plist } from '../server/service.js';

// A machine where everything passes; each test breaks one thing. No real CLI,
// git or network is touched; only the portState tests open loopback sockets.
const CONFIG = '/cfg/config.json';
// A home with a working ship skill for each CLI (symlinked, as gstack does).
function skillHome({ claude = true, codex = true } = {}) {
  const home = mkdtempSync(join(tmpdir(), 'doctor-skills-'));
  const real = join(home, 'gstack-src');
  mkdirSync(real, { recursive: true });
  writeFileSync(join(real, 'SKILL.md'), '---\nname: ship\n---\nbody\n');
  mkdirSync(join(home, '.claude/skills'), { recursive: true });
  mkdirSync(join(home, '.codex/skills'), { recursive: true });
  if (claude) symlinkSync(real, join(home, '.claude/skills/ship'));
  if (codex) symlinkSync(real, join(home, '.codex/skills/gstack-ship'));
  return { claudeDir: join(home, '.claude'), codexDir: join(home, '.codex'), agentsDir: join(home, '.agents'), home };
}
// Joined as checkPlugins joins it (backslashes on Windows).
const PLUGINS = join('/home/.claude', 'plugins', 'installed_plugins.json');
function probes(over = {}) {
  const files = {
    [CONFIG]: JSON.stringify({ repos: [{ path: '/r/app' }], jobs: [{ state: 'todo', agent: 'codex', repoPath: '/r/app' }, { state: 'done', agent: 'claude' }] }),
    [PLUGINS]: JSON.stringify({ plugins: {} }),
    ...over.files,
  };
  return {
    env: {},
    nodeVersion: '22.1.0',
    engines: '>=20.12',
    version: '0.40.1.0',
    loadPty: async () => ({}),
    which: (c) => `/bin/${c}`,
    scanAgents: async () => ['claude', 'codex'].map(cli => ({ cli, version: '1.0', path: `/bin/${cli}`, accounts: [{ isDefault: true, loggedIn: true }] })),
    billionAgent: () => 'claude',
    billionRuns: () => true,
    ghAccounts: async () => ['alice', 'bob'],
    ghAccountFor: async () => ({ login: 'bob', token: 'ghp_secret' }),
    git: async (args) => (args.includes('get-url') ? 'git@github.com:acme/app.git\n' : args.includes('ls-remote') ? 'abc\trefs/heads/main\n' : '.git'),
    repoEnv: (account) => ({ GH_TOKEN: account.token }),
    baseBranch: async () => 'main',
    exists: (p) => p in files || p.startsWith('/r/') || p.startsWith('/home/'),
    readFile: (p) => { if (!(p in files)) throw new Error('ENOENT'); return files[p]; },
    port: 7007,
    host: '127.0.0.1',
    portState: async () => 'free',
    configPath: CONFIG,
    worktreeDir: '/home/.agent-007/worktrees',
    claudeDir: '/home/.claude',
    ...skillHome(),
    settingsLine: 'Settings: ~/.agent-007/.env',
    service: () => null,
    whichIn: () => null,
    installCommand: 'agent007 install',
    tailscaleServe: async () => null,
    settingsFile: '/home/.agent-007/.env',
    npmLatest: async () => '0.40.0',
    telegramGetMe: async () => ({ username: 'my_bot' }),
    ...over,
  };
}
const board = (p) => ({ repos: ['/r/app'], jobs: [{ state: 'todo', agent: 'codex' }], ...p });
const origin = async () => 'https://github.com/acme/app.git';
const statuses = (lines) => lines.map(l => l.status);

describe('doctor checks', () => {
  it('versionAtLeast compares numerically', () => {
    expect(versionAtLeast('20.12.0', '20.12')).toBe(true);
    expect(versionAtLeast('20.9.0', '20.12')).toBe(false);
    expect(versionAtLeast('v22.0.0', '20.12')).toBe(true);
  });

  it('node: old Node and an unloadable node-pty are ✗ with a fix', async () => {
    expect(statuses(await checkNode(probes()))).toEqual(['ok', 'ok']);
    const lines = await checkNode(probes({ nodeVersion: '18.0.0', loadPty: async () => { throw new Error('bad ELF'); } }));
    expect(statuses(lines)).toEqual(['fail', 'fail']);
    expect(lines.every(l => l.fix)).toBe(true);
  });

  it('a missing CLI is ✗ only when Billion or a card uses it', async () => {
    const none = async () => [];
    const lines = await checkClis(probes({ scanAgents: none }), board());
    expect(lines[0]).toMatchObject({ status: 'fail', text: expect.stringContaining('Billion runs on it') });
    expect(lines[1]).toMatchObject({ status: 'fail', text: expect.stringContaining('a board card uses it') });
    const unused = await checkClis(probes({ scanAgents: none, billionRuns: () => false }), board({ jobs: [] }));
    expect(statuses(unused)).toEqual(['na', 'na']);
  });

  it('a logged-out CLI that is used is ✗ with its login command', async () => {
    const scan = async () => ['claude', 'codex'].map(cli => ({ cli, version: '1', path: `/bin/${cli}`, accounts: [{ isDefault: true, loggedIn: false }] }));
    const [claude, codex] = await checkClis(probes({ scanAgents: scan }), board());
    expect(claude).toMatchObject({ status: 'fail', fix: 'claude auth login' });
    expect(codex).toMatchObject({ status: 'fail', fix: 'codex login' });
  });

  it('gh: per-repo account, ✗ when none can see it, never a token', async () => {
    const lines = await checkGh(probes(), board(), origin);
    expect(statuses(lines)).toEqual(['ok', 'ok']);
    expect(lines[1].text).toBe('acme/app: board workers use bob');
    expect(JSON.stringify(lines)).not.toContain('ghp_secret');
    const none = await checkGh(probes({ ghAccountFor: async () => null }), board(), origin);
    expect(none[1].status).toBe('fail');
    const out = await checkGh(probes({ ghAccounts: async () => [] }), board(), origin);
    expect(out[0]).toMatchObject({ status: 'fail', fix: 'gh auth login' });
  });

  it('gh missing is ✗ only with a GitHub repo on the board; fast never asks gh', async () => {
    const noGh = (c) => (c === 'gh' ? null : `/bin/${c}`);
    expect((await checkGh(probes({ which: noGh }), board(), origin))[0].status).toBe('fail');
    expect((await checkGh(probes({ which: noGh }), board({ repos: [] }), origin))[0].status).toBe('na');
    const asked = [];
    const fast = await checkGh(probes({ ghAccounts: async () => { asked.push(1); return []; } }), board(), origin, { fast: true });
    expect(fast).toEqual([{ status: 'ok', text: 'gh installed' }]);
    expect(asked).toEqual([]);
  });

  it('repos: missing path, not git, no origin, no main on the remote', async () => {
    const at = async (p) => (await checkRepos(probes(p.probes), board(), p.origin || origin))[1];
    expect((await at({})).status).toBe('ok');
    expect((await at({ probes: { exists: () => false } })).text).toMatch(/does not exist/);
    expect((await at({ probes: { git: async (a) => { if (a.includes('rev-parse')) throw new Error('no'); return ''; } } })).text).toMatch(/not a git repository/);
    expect((await at({ origin: async () => null })).text).toMatch(/no origin remote/);
    expect((await at({ probes: { git: async (a) => (a.includes('ls-remote') ? '' : '.git') } })).text).toMatch(/no main branch/);
    expect((await checkRepos(probes({ which: () => null }), board(), origin))[0].status).toBe('fail');
  });

  it('repos: ls-remote runs as the repo\'s gh account', async () => {
    const envs = [];
    await checkRepos(probes({ git: async (a, t, env) => { if (a.includes('ls-remote')) envs.push(env); return 'x'; } }), board(), origin);
    expect(envs).toEqual([{ GH_TOKEN: 'ghp_secret', GIT_ASKPASS: '', GCM_INTERACTIVE: 'never' }]);
    // No ssh command of the owner's (core.sshCommand unset): ssh is told not to prompt.
    const batch = [];
    const unset = async (a, t, env) => { if (a.includes('config')) throw new Error('unset'); if (a.includes('ls-remote')) batch.push(env.GIT_SSH_COMMAND); return 'x'; };
    await checkRepos(probes({ git: unset }), board(), origin);
    await checkRepos(probes({ git: unset, env: { GIT_SSH_COMMAND: 'ssh -i k' } }), board(), origin);
    await checkRepos(probes({ git: unset, env: { GIT_SSH: '/usr/bin/plink' } }), board(), origin);
    expect(batch).toEqual(['ssh -o BatchMode=yes', undefined, undefined]);
  });

  it('remote: ✗ when tailscale serve proxies this port and ALLOWED_ORIGINS, as the server reads it, lacks the name', async () => {
    const served = (proxy) => async () => ({ Web: { 'mini.tail1.ts.net:443': { Handlers: { '/': { Proxy: proxy } } } } });
    expect(await checkRemote(probes({ tailscaleServe: async () => null }))).toEqual([{ status: 'na', text: expect.stringContaining('remote access not checked') }]);
    expect(await checkRemote(probes({ tailscaleServe: async () => ({}) }))).toEqual([{ status: 'na', text: expect.stringContaining('serves nothing') }]);
    expect(await checkRemote(probes({ tailscaleServe: served('http://127.0.0.1:9999') }))).toEqual([
      { status: 'fail', text: 'tailscale serve does not proxy port 7007 (it serves: http://127.0.0.1:9999)', fix: expect.stringContaining('tailscale serve --bg 7007') }]);
    const bare = await checkRemote(probes({ tailscaleServe: served('http://127.0.0.1:7007'), settingsFile: '/h/.agent-007/.env' }));
    expect(bare).toEqual([{ status: 'fail', text: expect.stringContaining('does not list mini.tail1.ts.net'), fix: 'Add ALLOWED_ORIGINS=mini.tail1.ts.net to /h/.agent-007/.env, then restart Agent 007' }]);
    expect(statuses(await checkRemote(probes({ tailscaleServe: served('http://localhost:7007'), env: { ALLOWED_ORIGINS: 'https://mini.tail1.ts.net' } })))).toEqual(['ok', 'na']);
    expect((await checkRemote(probes({ tailscaleServe: served('http://localhost:7007'), env: { ALLOWED_ORIGINS: 'https://mini.tail1.ts.net' } })))[1].text).toContain('https://mini.tail1.ts.net');
    // With a service, this shell's ALLOWED_ORIGINS (a ./.env here) does not count; its settings file does.
    const text = plist({ args: ['/n/node', '/a/bin/agent-007.js'], env: { PATH: '/svc' }, cwd: '/h/.agent-007', log: '/l' });
    const svc = (files) => probes({ tailscaleServe: served('http://127.0.0.1:7007'), env: { ALLOWED_ORIGINS: 'mini.tail1.ts.net' }, settingsFile: '/h/.agent-007/.env', service: () => ({ file: '/x.plist', text }), files });
    const [line] = await checkRemote(svc({}));
    expect(line).toMatchObject({ status: 'fail', text: expect.stringContaining('(as the service reads it)'), fix: 'Add ALLOWED_ORIGINS=mini.tail1.ts.net to /h/.agent-007/.env, then agent007 restart' });
    expect(statuses(await checkRemote(svc({ '/h/.agent-007/.env': 'ALLOWED_ORIGINS=other,mini.tail1.ts.net\n' })))).toEqual(['ok', 'na']);
    expect((await checkRemote(svc({ '/h/.agent-007/.env': 'ALLOWED_ORIGINS=other,mini.tail1.ts.net\n' })))[1].text).toBe('ALLOWED_ORIGINS (as the service reads it): other,mini.tail1.ts.net');
  });

  it('remote: reverse-proxy mode says its address, a wildcard HOST, and whether proxy headers arrive', async () => {
    const noTs = { tailscaleServe: async () => null, settingsFile: '/h/.agent-007/.env' };
    const url = 'https://agent.example.com';
    const seen = { at: Date.now() - 5000, proto: 'https', ip: '203.0.113.9', email: 'ada@example.com' };
    const run = (env, serverStatus) => checkRemote(probes({ ...noTs, env, serverStatus }));
    expect(await run({ PUBLIC_URL: url }, async () => ({ publicUrl: url, proxy: seen }))).toEqual([
      { status: 'ok', text: `remote access: reverse proxy at ${url} (PUBLIC_URL)` },
      { status: 'ok', text: 'proxy headers: last forwarded request 5s ago: https from 203.0.113.9, Cloudflare Access user ada@example.com' }]);
    expect((await run({ PUBLIC_URL: url }, async () => ({ publicUrl: url, proxy: null })))[1]).toMatchObject({ status: 'na', text: expect.stringContaining('no request has come through the proxy') });
    expect((await run({ PUBLIC_URL: url }, async () => ({ publicUrl: url, proxy: { ...seen, proto: 'http' } })))[1]).toMatchObject({ status: 'fail', text: expect.stringContaining('as http, not https') });
    expect((await run({ PUBLIC_URL: url }, async () => ({ publicUrl: null, proxy: null })))[1]).toMatchObject({ status: 'fail', text: expect.stringContaining('has PUBLIC_URL unset') });
    expect((await run({ PUBLIC_URL: url }, async () => null))[1]).toMatchObject({ status: 'na', text: expect.stringContaining('not running') });
    expect(statuses(await run({ PUBLIC_URL: url, HOST: '0.0.0.0' }, async () => null))).toEqual(['ok', 'fail', 'na']);
    expect(statuses(await run({ PUBLIC_URL: 'http://agent.example.com' }, async () => null))).toEqual(['fail', 'na']);
    expect(await run({ PUBLIC_URL: 'nope' })).toEqual([{ status: 'fail', text: expect.stringContaining('not an http(s) URL'), fix: expect.stringContaining('/h/.agent-007/.env') }]);
    // Tailscale's lines come first when both are set up.
    const both = await checkRemote(probes({ tailscaleServe: async () => ({ Web: { 'mini.tail1.ts.net:443': { Handlers: { '/': { Proxy: 'http://127.0.0.1:7007' } } } } }), env: { PUBLIC_URL: url, ALLOWED_ORIGINS: 'mini.tail1.ts.net' }, serverStatus: async () => null }));
    expect(both.map(l => l.text.split(' ')[0])).toEqual(['tailscale', 'ALLOWED_ORIGINS:', 'remote', 'Agent']);
  });

  it('service: nothing when none is installed; ✗ for a gone node or bin, or a CLI its PATH misses', () => {
    expect(checkService(probes())).toEqual([]);
    const text = plist({ args: ['/n/node', '/a/bin/agent-007.js'], env: { PATH: '/svc' }, cwd: '/a', log: '/l' });
    const svc = (over) => probes({ service: () => ({ file: '/h/Library/LaunchAgents/x.plist', text }), whichIn: (cmd, PATH) => (PATH === '/svc' && cmd !== 'gh' ? `/svc/${cmd}` : null), ...over });
    const lines = checkService(svc({ exists: (p) => p === '/a/bin/agent-007.js' }));
    expect(lines.map(l => l.status)).toEqual(['fail', 'fail']);
    expect(lines[0].text).toBe('the service runs node from /n/node, which is gone');
    expect(lines[1].text).toBe('the service cannot find gh: /bin/gh is not on its PATH');
    expect(lines[1].fix).toMatch(/^agent007 install/);
    const good = checkService(svc({ exists: () => true, whichIn: (cmd) => `/svc/${cmd}` }));
    expect(good).toEqual([{ status: 'ok', text: 'service /h/Library/LaunchAgents/x.plist runs /n/node and finds what this shell finds' }]);
  });

  it('port: free, held by Agent 007 (✗ only when starting), held by something else', async () => {
    expect((await checkPort(probes()))[0].status).toBe('ok');
    const ours = probes({ portState: async () => 'agent-007' });
    expect((await checkPort(ours))[0]).toMatchObject({ status: 'ok', text: expect.stringContaining('this Agent 007') });
    expect((await checkPort(ours, { starting: true }))[0].status).toBe('fail');
    expect((await checkPort(probes({ portState: async () => 'other' })))[0]).toMatchObject({ status: 'fail', fix: expect.stringContaining('--port 7008') });
  });

  it('settings: the settings line, and config.json parses / missing / broken', () => {
    expect(statuses(checkSettings(probes(), board()))).toEqual(['ok', 'ok']);
    expect(checkSettings(probes(), { missing: true, repos: [], jobs: [] })[1].status).toBe('na');
    expect(checkSettings(probes(), { error: 'Unexpected token', repos: [], jobs: [] })[1].status).toBe('fail');
  });

  it('version: latest, behind (named, not ✗), offline (–)', async () => {
    // Value: protects=VERSION compared in npm's A.B.(C*1000+D) form; fails_when=0.40.1.0 reads as behind npm's 0.40.1000; why_new=release encoding; seam=none
    expect(toNpm('0.40.1.0')).toBe('0.40.1000');
    expect(fromNpm('0.40.1002')).toBe('0.40.1.2');
    expect((await checkVersion(probes({ npmLatest: async () => '0.40.1000' })))[0].text).toBe('version 0.40.1.0, the latest');
    expect((await checkVersion(probes({ npmLatest: async () => '0.41.0' })))[0]).toMatchObject({ status: 'ok', text: expect.stringContaining('0.41.0.0 is out') });
    expect((await checkVersion(probes({ npmLatest: async () => '0.40.1001' })))[0].text).toContain('0.40.1.1 is out');
    expect((await checkVersion(probes({ npmLatest: async () => null })))[0].status).toBe('na');
  });

  it('telegram: off, answers, rejected, unreachable; the token never shows', async () => {
    expect((await checkTelegram(probes()))[0].status).toBe('na');
    const env = { TELEGRAM_BOT_TOKEN: '123:SECRET' };
    const all = [
      await checkTelegram(probes({ env })),
      await checkTelegram(probes({ env, telegramGetMe: async () => ({ rejected: true }) })),
      await checkTelegram(probes({ env, telegramGetMe: async () => null })),
    ];
    expect(all.map(l => l[0].status)).toEqual(['ok', 'fail', 'na']);
    expect(JSON.stringify(all)).not.toContain('SECRET');
  });

  it('whisper: off when Telegram not set up; – when not set up but Telegram is; – when ffmpeg missing; ✓ when fully set up', () => {
    // Telegram not set up: whisper.cpp info line
    expect(checkWhisper(probes({ env: {} }))[0].status).toBe('na');
    expect(checkWhisper(probes({ env: {} }))[0].text).toMatch(/only used for Telegram/);

    // Telegram set up, whisper.cpp not set up
    const setup = (ok) => ok ? { bin: 'whisper-cpp', model: '/path/to/model.bin' } : { missing: 'brew install whisper-cpp' };
    const telegramEnv = { TELEGRAM_BOT_TOKEN: 'token' };
    const notSet = checkWhisper(probes({ env: telegramEnv }), { whisperSetupFn: () => setup(false) })[0];
    expect(notSet.status).toBe('na');
    expect(notSet.text).toMatch(/not set up/);
    expect(notSet.text).toMatch(/brew install whisper-cpp/);

    // Telegram set up, whisper.cpp set up, ffmpeg missing
    const noFFmpeg = checkWhisper(probes({ env: telegramEnv, which: (c) => c === 'ffmpeg' ? null : `/bin/${c}` }), { whisperSetupFn: () => setup(true) })[0];
    expect(noFFmpeg.status).toBe('na');
    expect(noFFmpeg.text).toMatch(/ffmpeg not on PATH/);

    // Telegram set up, whisper.cpp set up, ffmpeg available
    const full = checkWhisper(probes({ env: telegramEnv }), { whisperSetupFn: () => setup(true) })[0];
    expect(full.status).toBe('ok');
    expect(full.text).toMatch(/voice notes over Telegram are transcribed/);
  });

  it('plugins: local registrations in a board worktree or a gone folder are ✗', () => {
    const files = { [PLUGINS]: JSON.stringify({ plugins: {
      // worktrees-old sits beside the worktree folder, not in it.
      'tg@x': [{ scope: 'local', projectPath: '/home/.agent-007/worktrees/app-1' }, { scope: 'local', projectPath: '/gone' }, { scope: 'local', projectPath: '/r/app' }, { scope: 'local', projectPath: '/home/.agent-007/worktrees-old/app' }],
      'p@x': [{ scope: 'user' }],
    } }) };
    const lines = checkPlugins(probes({ files, claudeDir: '/home/.claude' }));
    expect(statuses(lines)).toEqual(['fail', 'fail']);
    expect(lines[0].fix).toMatch(/^cd \/home\/\.agent-007\/worktrees\/app-1 && claude plugin uninstall tg@x --scope local/);
    // A gone folder is made again for the uninstall, which applies where it runs.
    expect(lines[1].fix).toMatch(/^mkdir -p \/gone && cd \/gone && claude plugin uninstall tg@x --scope local && rmdir \/gone/);
    expect(checkPlugins(probes({ claudeDir: '/home/.claude' }))[0].status).toBe('ok');
  });
});

describe('runDoctor', () => {
  it('all passing: nothing failed, report has no ✗, start prints nothing', async () => {
    const results = await runDoctor({ probes: probes() });
    expect(failed(results)).toBe(false);
    expect(formatReport(results)).not.toContain('✗');
    expect(formatStartup(results, 'agent-007 doctor')).toBe('');
  });

  it('a ✗ fails the run and prints its fix; start prints only ✗ lines and the doctor command', async () => {
    const results = await runDoctor({ probes: probes({ portState: async () => 'other' }) });
    expect(failed(results)).toBe(true);
    expect(formatReport(results)).toMatch(/✗ port 7007 is in use by another program\n {4}fix: /);
    const start = formatStartup(results, 'npm start -- doctor');
    expect(start).not.toContain('✓');
    expect(start.split('\n').at(-1)).toBe('  Run `npm start -- doctor` for details.');
  });

  it('fast runs no network check', async () => {
    const net = () => { throw new Error('network in fast mode'); };
    const results = await runDoctor({ fast: true, probes: probes({ npmLatest: net, telegramGetMe: net, ghAccounts: net, ghAccountFor: net, repoEnv: net }) });
    expect(failed(results)).toBe(false);
    expect(results.map(r => r.title)).toEqual(['Node', 'Git', 'Port', 'Agent CLIs', 'GitHub', 'Skills', 'Settings', 'Recommended']);
  });

  it('fast keeps to its budget and drops a check that hangs', async () => {
    const started = Date.now();
    const results = await runDoctor({ fast: true, budgetMs: 300, probes: probes({ portState: () => new Promise(() => {}) }) });
    expect(Date.now() - started).toBeLessThan(1000);
    expect(results.map(r => r.title)).not.toContain('Port');
    expect(results.map(r => r.title)).toContain('Node');
  });

  it('a check that throws is one ✗, not a crash', async () => {
    const results = await runDoctor({ probes: probes({ portState: async () => { throw new Error('boom'); } }) });
    expect(formatReport(results)).toContain('✗ Port check failed: boom');
  });
});

describe('doctor gaps', () => {
  // Value: protects=readBoard (done cards ignored, card repos merged and deduped, broken JSON is ✗ not a crash); fails_when=the done filter, dedupe or parse catch is dropped; why_new=checkSettings was only fed hand-built boards; seam=none
  it('reads config.json: done cards ignored, card repos merged, broken JSON is a Settings ✗', async () => {
    const files = { [CONFIG]: JSON.stringify({
      repos: [{ path: '/r/app' }],
      jobs: [{ state: 'todo', agent: 'codex', repoPath: '/r/app' }, { state: 'todo', agent: 'codex', repoPath: '/r/lib' }, { state: 'done', agent: 'claude', repoPath: '/r/old' }],
    }) };
    const ok = await runDoctor({ probes: probes({ files, billionRuns: () => false }) });
    const by = (rs, t) => rs.find(r => r.title === t).lines;
    expect(by(ok, 'Settings')[1].text).toMatch(/parses \(2 repos, 2 open cards\)/);
    expect(by(ok, 'Agent CLIs')[0].status).toBe('ok');
    const noClaude = await runDoctor({ probes: probes({ files, billionRuns: () => false, scanAgents: async () => [] }) });
    expect(statuses(by(noClaude, 'Agent CLIs'))).toEqual(['na', 'fail']);
    const broken = await runDoctor({ probes: probes({ files: { [CONFIG]: '{nope' } }) });
    expect(by(broken, 'Settings')[1]).toMatchObject({ status: 'fail', text: expect.stringContaining('does not parse') });
    expect(failed(broken)).toBe(true);
  });

  // Value: protects=fast run passes starting=true to checkPort; fails_when=a start no longer warns that another Agent 007 holds the port (or doctor wrongly ✗s its own server); why_new=checkPort was only called directly; seam=none
  it('another Agent 007 on the port is ✗ at start, ✓ under doctor', async () => {
    const p = probes({ portState: async () => 'agent-007' });
    const start = await runDoctor({ fast: true, probes: p });
    expect(formatStartup(start, 'agent-007 doctor')).toContain('held by another Agent 007');
    expect(failed(await runDoctor({ probes: p }))).toBe(false);
  });

  // Value: protects=checkRepos unreachable-origin branch, non-GitHub remote skipping gh env, base-branch fallback; fails_when=stderr is dropped, gh token is asked for a non-GitHub remote, or a baseBranch error crashes the check; why_new=no test hit the catch or the non-GitHub path; seam=none
  it('repos: unreachable origin shows stderr; non-GitHub remote runs without gh env; base falls back to main', async () => {
    const err = Object.assign(new Error('Command failed'), { stderr: 'fatal: could not read Username\nmore' });
    const [, down] = await checkRepos(probes({ git: async (a) => { if (a.includes('ls-remote')) throw err; return '.git'; } }), board(), origin);
    expect(down).toMatchObject({ status: 'fail', text: '/r/app: could not reach origin (fatal: could not read Username)' });
    // Value: protects=no token in a git error reaches the screen; fails_when=the userinfo redaction is dropped; why_new=only plain stderr was covered; seam=none
    // Assembled, so no literal credential URL sits in the repo for secret scanners.
    const leak = Object.assign(new Error('x'), { stderr: `fatal: unable to access 'https://${['bob', 'ghp_secret'].join(':')}@github.com/acme/app.git/'` });
    const [, redacted] = await checkRepos(probes({ git: async (a) => { if (a.includes('ls-remote')) throw leak; return '.git'; } }), board(), origin);
    expect(redacted.text).toContain('https://***@github.com/acme/app.git');
    expect(redacted.text).not.toContain('ghp_secret');
    const envs = []; const heads = [];
    const plain = probes({
      repoEnv: () => { throw new Error('asked gh for a non-GitHub repo'); },
      baseBranch: async () => { throw new Error('no base'); },
      git: async (a, t, env) => { if (a.includes('ls-remote')) { envs.push(env); heads.push(a.at(-1)); } return 'x'; },
    });
    const [, line] = await checkRepos(plain, board(), async () => 'git@gitlab.com:acme/app.git');
    // No base branch known: any branch on origin will do, as the board branches from HEAD.
    expect(line).toMatchObject({ status: 'ok', text: '/r/app: origin has branches' });
    expect(envs).toEqual([{ GIT_ASKPASS: '', GCM_INTERACTIVE: 'never' }]);
    expect(heads).toEqual(['origin']);
  });

  // Value: protects=checkClis unknown-login and unused-logged-out branches; fails_when=an unknown login turns into ✗ or an unused logged-out CLI fails the run; why_new=only loggedIn true/false-and-used were covered; seam=none
  it('clis: login unknown is ✗ when used, – when not; logged out but unused is –', async () => {
    const scan = (loggedIn) => async () => ['claude', 'codex'].map(cli => ({ cli, path: `/bin/${cli}`, accounts: [{ isDefault: true, loggedIn }] }));
    const [unknown] = await checkClis(probes({ scanAgents: scan(null) }), board());
    expect(unknown).toMatchObject({ status: 'fail', text: expect.stringContaining('(version unknown)'), fix: 'claude auth status' });
    expect(unknown.text).toContain('could not tell whether it is logged in');
    const idle = await checkClis(probes({ scanAgents: scan(null), billionRuns: () => false }), board({ jobs: [] }));
    expect(idle[0]).toMatchObject({ status: 'na', text: expect.stringContaining('login not known') });
    const unused = await checkClis(probes({ scanAgents: scan(false), billionRuns: () => false }), board({ jobs: [] }));
    expect(statuses(unused)).toEqual(['na', 'na']);
  });

  // Value: protects=checkGh catch on ghAccountFor and checkPlugins unreadable-file path; fails_when=a rejecting gh lookup crashes the GitHub check or a missing plugins file becomes ✗; why_new=neither error path was exercised; seam=none
  it('gh: a lookup that rejects is a per-repo ✗; plugins: no registrations file is –', async () => {
    const lines = await checkGh(probes({ ghAccountFor: async () => { throw new Error('rate limited'); } }), board(), origin);
    expect(statuses(lines)).toEqual(['ok', 'fail']);
    expect(lines[1].text).toBe('acme/app: no signed-in gh account can see it');
    expect(checkPlugins(probes({ claudeDir: '/elsewhere' }))).toEqual([{ status: 'na', text: 'no Claude Code plugin registrations' }]);
  });
});

describe('doctor review follow-ups', () => {
  afterEach(() => vi.unstubAllGlobals());

  // Value: protects=no gh credentials asked for when gh is absent; fails_when=the which('gh') guard goes; why_new=only gh-present was covered; seam=none
  it('repos: a GitHub remote without gh runs ls-remote with no gh env', async () => {
    const envs = [];
    const p = probes({
      which: (c) => (c === 'gh' ? null : `/bin/${c}`),
      repoEnv: () => { throw new Error('asked gh without gh'); },
      git: async (a, t, env) => { if (a.includes('ls-remote')) envs.push(env); return 'x'; },
    });
    expect((await checkRepos(p, board(), origin))[1].status).toBe('ok');
    expect(envs).toEqual([{ GIT_ASKPASS: '', GCM_INTERACTIVE: 'never' }]);
  });

  // Value: protects=one account walk per repo shared by GitHub and Repos; fails_when=each check walks again; why_new=the walk asks GitHub per account; seam=none
  it('the account walk runs once per repo across checks', async () => {
    const walked = [];
    await runDoctor({ probes: probes({ ghAccountFor: async (r) => { walked.push(r); return { login: 'bob', token: 't' }; } }) });
    expect(walked).toEqual(['/r/app']);
  });

  // Value: protects=a port that cannot be listened on is named as such; fails_when=EACCES reads as "in use"; why_new=new state; seam=none
  it('port: a listen error other than in-use says so', async () => {
    const [line] = await checkPort(probes({ portState: async () => 'EACCES' }));
    expect(line).toMatchObject({ status: 'fail', text: 'cannot listen on 127.0.0.1 port 7007 (EACCES)' });
  });

  // Value: protects=the real port probe (free / ours by page title / other); fails_when=the title match or the in-use branch changes; why_new=every other test stubs it; seam=none
  it('portState tells free, Agent 007 and another program apart on loopback', async () => {
    const listen = (srv) => new Promise(r => srv.listen(0, '127.0.0.1', () => r(srv.address().port)));
    const ours = createHttpServer((req, res) => res.end('<html><title>Agent 007</title></html>'));
    const sockets = [];
    const other = createServer((sock) => sockets.push(sock));
    const [a, b] = [await listen(ours), await listen(other)];
    try {
      expect(await portState(a, '127.0.0.1')).toBe('agent-007');
      expect(await portState(b, '127.0.0.1')).toBe('other');
    } finally {
      ours.closeAllConnections();
      sockets.forEach(sock => sock.destroy());
      await Promise.all([new Promise(r => ours.close(r)), new Promise(r => other.close(r))]);
    }
    expect(await portState(b, '127.0.0.1')).toBe('free');
  });

  // Value: protects=only 401/404 count as a rejected token; fails_when=a 429 or 5xx fails the doctor run; why_new=getMe was stubbed everywhere; seam=none
  it('telegramGetMe: answers, rejected on 401/404, unknown otherwise, never the token', async () => {
    const env = { TELEGRAM_BOT_TOKEN: '123:SECRET' };
    const reply = (status, body) => vi.stubGlobal('fetch', vi.fn(async () => ({ status, json: async () => body })));
    reply(200, { ok: true, result: { username: 'my_bot' } });
    expect(await telegramGetMe(env)).toEqual({ username: 'my_bot' });
    reply(401, { ok: false, error_code: 401, description: 'Unauthorized' });
    expect(await telegramGetMe(env)).toEqual({ rejected: true });
    reply(429, { ok: false, error_code: 429, description: 'Too Many Requests' });
    expect(await telegramGetMe(env)).toBeNull();
    vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('fetch failed for https://api.telegram.org/bot123:SECRET/getMe'); }));
    expect(await telegramGetMe(env)).toBeNull();
  });

  // Value: protects=gh not needed without a GitHub repo; fails_when=a GitLab-only board fails on a signed-out gh; why_new=reviewers found it; seam=none
  it('gh: signed out is – when no board repo is on GitHub', async () => {
    const lines = await checkGh(probes({ ghAccounts: async () => [] }), board(), async () => 'git@gitlab.com:acme/app.git');
    expect(lines).toEqual([{ status: 'na', text: 'gh installed (no GitHub repo on the board)' }]);
  });

  // Value: protects=local-only repos pass unless a PR card needs a remote; fails_when=every local repo fails doctor; why_new=reviewers found it; seam=none
  it('repos: no origin is – unless a card on it needs a pull request', async () => {
    const none = async () => null;
    expect((await checkRepos(probes(), board({ jobs: [{ state: 'todo', repoPath: '/r/app', requiresPr: false }] }), none))[1].status).toBe('na');
    expect((await checkRepos(probes(), board({ jobs: [{ state: 'todo', repoPath: '/r/app' }] }), none))[1]).toMatchObject({ status: 'fail', text: expect.stringContaining('needs a pull request') });
  });

  // Value: protects=fix lines that paste into a shell, and the right place to remove a repo; fails_when=a spaced path splits, or a card-only repo points at the Explorer; why_new=reviewers found it; seam=none
  it('repos: fix lines quote a path with spaces; a repo only cards name points at the cards', async () => {
    const spaced = '/r/My Project';
    const [, line] = await checkRepos(probes({ git: async (a) => { if (a.includes('rev-parse')) throw new Error('no'); return ''; } }), board({ repos: [spaced] }), origin);
    expect(line.fix).toBe(`git -C '${spaced}' status`);
    // Nothing in a pasted path expands: $, backticks and quotes stay literal.
    const odd = "/r/it's $HOME `x`";
    const [, oddLine] = await checkRepos(probes({ git: async (a) => { if (a.includes('rev-parse')) throw new Error('no'); return ''; } }), board({ repos: [odd] }), origin);
    expect(oddLine.fix).toBe("git -C '/r/it'\\''s $HOME `x`' status");
    const gone = probes({ exists: () => false });
    expect((await checkRepos(gone, board({ explorer: new Set(['/r/app']) }), origin))[1].fix).toMatch(/Explorer/);
    expect((await checkRepos(gone, board({ explorer: new Set() }), origin))[1].fix).toMatch(/board cards that name it/);
  });

  // Value: protects=no config.json text in the report; fails_when=V8's quoted snippet is printed; why_new=both adversarial reviews; seam=none
  it('a broken config.json is named by position only, never its text', async () => {
    const results = await runDoctor({ probes: probes({ files: { [CONFIG]: '{"token": ghp_SECRETSECRET' } }) });
    const report = formatReport(results);
    expect(report).toMatch(/config\.json does not parse: not valid JSON/);
    expect(report).not.toContain('SECRET');
  });

  // Value: protects=the worktree-folder test on Windows paths; fails_when=containment goes back to a case- and slash-sensitive prefix; why_new=Windows CI; seam=none
  it('insideDir: inside, the folder itself, a sibling, and Windows case and slashes', () => {
    expect(insideDir('/a/worktrees', '/a/worktrees/app-1', posix)).toBe(true);
    expect(insideDir('/a/worktrees', '/a/worktrees', posix)).toBe(true);
    expect(insideDir('/a/worktrees', '/a/worktrees-old/app', posix)).toBe(false);
    expect(insideDir('/a/worktrees', '/a', posix)).toBe(false);
    const wt = 'C:\\Users\\Me\\.agent-007\\worktrees';
    expect(insideDir(wt, 'c:\\users\\me\\.agent-007\\worktrees\\app-1', win32)).toBe(true);
    expect(insideDir(wt, 'C:/Users/Me/.agent-007/worktrees/app-1', win32)).toBe(true);
    expect(insideDir(wt, 'C:\\Users\\Me\\.agent-007\\worktrees-old\\app', win32)).toBe(false);
    expect(insideDir(wt, 'D:\\Users\\Me\\.agent-007\\worktrees\\app-1', win32)).toBe(false);
  });
});

describe('doctor skills', () => {
  const codexBoard = { jobs: [{ state: 'todo', agent: 'codex' }] };
  const idle = { billionRuns: () => false };

  it('a symlinked ship skill passes for each CLI, codex under its gstack-ship name', () => {
    const lines = checkSkills(probes(), codexBoard);
    expect(statuses(lines.slice(0, 2))).toEqual(['ok', 'ok']);
    expect(lines[0].text).toMatch(/^gstack for claude: ship skill at .*ship$/);
  });

  it('no gstack is ✗ only for a CLI that is used for a PR card', () => {
    const p = probes({ ...skillHome({ claude: false, codex: false }), ...idle });
    const used = checkSkills(p, codexBoard);
    expect(used[0].status).toBe('na');
    expect(used[1]).toMatchObject({ status: 'fail', fix: expect.stringContaining('setup --host codex') });
    expect(used[1].fix).toContain('https://github.com/garrytan/gstack');
    expect(checkSkills(p, { jobs: [{ state: 'todo', agent: 'codex', requiresPr: false }] })[1].status).toBe('na');
    expect(checkSkills(p, { jobs: [] })[1].status).toBe('na');
  });

  it('a dangling link is ✗ and is never deleted', () => {
    const h = skillHome();
    symlinkSync(join(h.home, 'gone'), join(h.codexDir, 'skills/gstack-qa'));
    const lines = checkSkills(probes({ ...h, ...idle }), { jobs: [] });
    const bad = lines.find(l => l.status === 'fail');
    expect(bad.text).toMatch(/^1 skill in .*skills is a broken link \(e\.g\. gstack-qa → /);
    expect(bad.fix).toContain('setup --host codex');
    expect(checkSkills(probes({ ...h, ...idle }), { jobs: [] }).filter(l => l.status === 'fail')).toHaveLength(1);
  });

  it('agent-browser is ✓ or –, never ✗', () => {
    const last = (which) => checkSkills(probes({ which }), { jobs: [] }).at(-1);
    expect(last((c) => `/bin/${c}`).status).toBe('ok');
    expect(last(() => null).text).toContain('agent-browser (recommended)');
    expect(last(() => null).status).toBe('na');
  });

  it('impeccable is ✓ or – per CLI, found by its front matter name, never ✗', () => {
    const h = skillHome();
    mkdirSync(join(h.home, '.claude/skills/impeccable'), { recursive: true });
    writeFileSync(join(h.home, '.claude/skills/impeccable/SKILL.md'), '---\nname: impeccable\n---\n');
    const lines = checkSkills(probes({ ...h, ...idle }), { jobs: [] }).filter(l => l.text.startsWith('impeccable'));
    expect(lines.map(l => l.status)).toEqual(['ok', 'na']);
    expect(lines[1].text).toContain('impeccable (recommended) for codex: not installed');
  });

  it('the fast run includes the skills check', async () => {
    const results = await runDoctor({ fast: true, probes: probes() });
    expect(results.map(r => r.title)).toContain('Skills');
  });
});

describe('doctor report layout', () => {
  const ESC = '\x1b[';

  it('files every check line under exactly one section, in the fixed order', async () => {
    const results = await runDoctor({ probes: probes() });
    expect(results.every(r => SECTIONS.includes(r.section))).toBe(true);
    const headings = formatReport(results).split('\n').filter(l => SECTIONS.includes(l));
    expect(headings).toEqual(SECTIONS.filter(h => results.some(r => r.section === h)));
    expect(headings).toContain('System');
    expect(headings).toContain('Recommended');
    // Each ✓/✗/– line is printed once.
    const total = results.reduce((n, r) => n + r.lines.length, 0);
    expect(formatReport(results).split('\n').filter(l => /^[✓✗–] /.test(l))).toHaveLength(total);
  });

  it('streams sections in order; a slow one holds back only what follows it', async () => {
    const seen = [];
    const slowPort = () => new Promise(r => setTimeout(() => r('free'), 150));
    const slowVersion = probes({ portState: slowPort, npmLatest: () => new Promise(r => setTimeout(() => r('9.9.9'), 400)) });
    const started = Date.now();
    await runDoctor({ probes: slowVersion, onSection: ({ heading }) => seen.push([heading, Date.now() - started]) });
    expect(seen.map(s => s[0])).toEqual(SECTIONS.filter(h => seen.some(s => s[0] === h)));
    const at = Object.fromEntries(seen);
    expect(at.System).toBeGreaterThanOrEqual(140);
    expect(at['Settings & service']).toBeGreaterThanOrEqual(390);
  });

  it('prints an early section while a later one is still running', async () => {
    const seen = [];
    let release;
    const hang = new Promise(r => { release = r; });
    const run = runDoctor({ probes: probes({ ghAccounts: () => hang }), onSection: ({ heading }) => seen.push(heading) });
    await new Promise(r => setTimeout(r, 100));
    expect(seen).toContain('System');
    expect(seen).toContain('Agents');
    expect(seen).not.toContain('Repos');
    release(['bob']);
    await run;
    expect(seen).toContain('Repos');
  });

  it('colors only when asked: TTY, FORCE_COLOR, not NO_COLOR', () => {
    expect(useColor({ isTTY: true }, {})).toBe(true);
    expect(useColor({ isTTY: false }, {})).toBe(false);
    expect(useColor({ isTTY: true }, { NO_COLOR: '1' })).toBe(false);
    expect(useColor({ isTTY: false }, { FORCE_COLOR: '1' })).toBe(true);
    expect(useColor({ isTTY: true }, { FORCE_COLOR: '0' })).toBe(false);
  });

  it('plain output has no ANSI; colored output paints ✓ green, ✗ red with its fix, – dim, headings bold', async () => {
    const results = await runDoctor({ probes: probes({ portState: async () => 'other' }) });
    expect(formatReport(results)).not.toContain(ESC);
    const colored = formatReport(results, { color: true });
    expect(colored).toContain(`${ESC}1mSystem${ESC}0m`);
    expect(colored).toContain(`${ESC}32m✓${ESC}0m`);
    expect(colored).toMatch(/\x1b\[31m✗ port 7007 is in use by another program\x1b\[0m\n\x1b\[31m {4}fix: /);
    const dim = formatSection({ heading: 'X', results: [{ lines: [{ status: 'na', text: 'nope' }] }] }, { color: true });
    expect(dim).toContain(`${ESC}2m– nope${ESC}0m`);
  });

  it('ends with the summary: counts, or All good', () => {
    const r = (...statuses) => [{ lines: statuses.map(status => ({ status, text: 't' })) }];
    expect(formatSummary(r('ok'))).toBe('All good');
    expect(formatSummary(r('ok', 'na', 'na'))).toBe('All good, 2 notes');
    expect(formatSummary(r('fail', 'na'))).toBe('1 problem, 1 note');
    expect(formatSummary(r('fail', 'fail'))).toBe('2 problems, 0 notes');
    expect(formatReport(r('fail')).split('\n').at(-1)).toBe('1 problem, 0 notes');
  });
});

describe('doctor stale orphans', () => {
  const orphan = (name, over = {}) => ({ name, reason: 'unpushed', repoPath: '/r/app', branchName: `bill/${name}`, worktreePath: `/home/.agent-007/worktrees/app-1/${name}`, ...over });
  // dirty: worktree paths with uncommitted files; ahead: commitsNotInBase per name.
  const machine = ({ dirty = [], ahead = {}, gone = [] } = {}) => probes({
    // checkOrphans joins paths, which on Windows turns / into a backslash.
    exists: (p) => { const at = p.replace(/\\/g, '/'); return !gone.some(n => at.endsWith(`/${n}`)) && (at.startsWith('/r/') || at.startsWith('/home/')); },
    git: async (a) => (a.includes('status') ? (dirty.some(n => a[1].endsWith(`/${n}`)) ? ' M file\n' : '') : ''),
    commitsNotInBase: async (o) => ahead[o.name] ?? 0,
  });

  it('lists a clean orphan whose work is on origin/main, with how to remove it', async () => {
    const lines = await checkOrphans(machine(), board({ orphans: [orphan('Spectre')] }));
    expect(lines).toHaveLength(1);
    expect(lines[0]).toMatchObject({ status: 'fail', text: expect.stringContaining('Spectre') });
    expect(lines[0].text).toContain('already on main');
    expect(lines[0].fix).toMatch(/Explorer.*restart Agent 007/);
    // Only an "unpushed" orphan is released by a restart.
    const [restart] = await checkOrphans(machine(), board({ orphans: [orphan('Ember', { reason: 'server-restart' })] }));
    expect(restart.fix).not.toMatch(/restart/);
  });

  it('keeps dirty orphans, real work, undecidable ones and those an open card may re-adopt', async () => {
    const orphans = [orphan('Ghost'), orphan('Viper'), orphan('Onyx'), orphan('Cobra'), orphan('Raven')];
    const jobs = [{ state: 'review', repoPath: '/r/app', branchName: 'bill/Cobra' }];
    const lines = await checkOrphans(machine({ dirty: ['Ghost'], ahead: { Viper: 2, Onyx: -1 } }), board({ orphans, jobs }));
    expect(lines.filter(l => l.status === 'fail').map(l => l.text)).toEqual([expect.stringContaining('Raven')]);
    expect(lines.at(-1)).toMatchObject({ status: 'ok', text: expect.stringMatching(/^4 orphaned worktrees kept/) });
  });

  it('lists an orphan whose folder is gone, and says so when there are none', async () => {
    const [line] = await checkOrphans(machine({ gone: ['Dagger'] }), board({ orphans: [orphan('Dagger')] }));
    expect(line).toMatchObject({ status: 'fail', text: expect.stringContaining('its folder is gone') });
    expect(await checkOrphans(machine(), board({ orphans: [] }))).toEqual([{ status: 'ok', text: 'no orphaned worktrees' }]);
  });

  it('keeps an orphan whose status, base branch or count git cannot read', async () => {
    const orphans = [orphan('Shade'), orphan('Wisp'), orphan('Moth')];
    const p = {
      ...machine(),
      git: async (a) => { if (a[1].endsWith('/Shade')) throw new Error('not a git repository'); return ''; },
      baseBranch: async (repo) => { throw new Error('no base'); },
      commitsNotInBase: (o) => { if (o.name === 'Moth') throw new Error('sync throw'); return 0; },
    };
    expect(await checkOrphans(p, board({ orphans }))).toEqual([{ status: 'ok', text: expect.stringMatching(/^3 orphaned worktrees kept/) }]);
    const one = await checkOrphans({ ...machine(), baseBranch: async () => null }, board({ orphans: [orphan('Wisp')] }));
    expect(one).toEqual([{ status: 'ok', text: expect.stringMatching(/^1 orphaned worktree kept:/) }]);
  });

  it('skips malformed orphan entries in config.json', async () => {
    const orphans = [null, { name: 'NoPath', repoPath: '/r/app' }, { name: 'NoRepo', worktreePath: '/home/x' }, orphan('Kite')];
    const files = { [CONFIG]: JSON.stringify({ repos: [{ path: '/r/app' }], jobs: [], orphans }) };
    const results = await runDoctor({ probes: { ...machine(), files, readFile: (p) => files[p], exists: () => true } });
    const stale = results.find(r => r.title === 'Stale orphans');
    expect(stale.lines).toHaveLength(1);
    expect(stale.lines[0].text).toContain('Kite');
  });

  it('leaves out a folder that is no longer a worktree', async () => {
    // status there would answer for whatever repo holds the folder.
    const p = { ...machine(), exists: (path) => !path.endsWith(join('Husk', '.git')) };
    const lines = await checkOrphans(p, board({ orphans: [orphan('Husk', { reason: 'broken-worktree' })] }));
    expect(lines).toEqual([expect.objectContaining({ status: 'ok', text: expect.stringMatching(/^1 orphaned worktree kept/) })]);
  });

  it('reads the orphans from config.json for a full run', async () => {
    const files = { [CONFIG]: JSON.stringify({ repos: [{ path: '/r/app' }], jobs: [], orphans: [orphan('Falcon')] }) };
    const results = await runDoctor({ probes: { ...machine(), files, readFile: (p) => files[p], exists: () => true } });
    const stale = results.find(r => r.title === 'Stale orphans');
    expect(stale.section).toBe('Repos');
    expect(stale.lines[0].text).toContain('Falcon');
  });
});