Ctrl K
ComunidadGuías…

test/voice-setup.test.js

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

Archivo original ↗Preguntar a KiBot
import { describe, it, expect } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { createHash } from 'crypto';
import { setupVoice, setEnvLine } from '../server/voice-setup.js';
import { runCommand } from '../server/service.js';
import { removeTempDir } from './temp-dir.js';

const BODY = Buffer.from('fake ggml model bytes');
const SHA = createHash('sha256').update(BODY).digest('hex');

// Never real brew, whisper or network, and a temp HOME.
function fakeCtx(over = {}) {
  const home = mkdtempSync(join(tmpdir(), 'a007-voice-'));
  const out = [], calls = [], asked = [];
  const present = new Set(over.present || []);
  const cfg = join(home, '.agent-007');
  const ctx = {
    home, out, calls, asked, cfg,
    platform: 'darwin', tty: true, yes: false,
    env: { HOME: home, PATH: '/usr/bin', AGENT007_CONFIG_DIR: cfg },
    cwd: home,
    has: (n) => present.has(n),
    run: async (cmd, args) => {
      calls.push([cmd, ...args].join(' '));
      if (cmd === 'brew') { present.add('whisper-cli'); present.add('ffmpeg'); }
      return { code: 0, stdout: cmd === 'whisper-cli' ? ' [BLANK_AUDIO] hello there\n' : '', stderr: '', ...(await over.answer?.(cmd, args)) };
    },
    fetch: async (url) => String(url).includes('/api/') ? Response.json([{ path: 'ggml-base.en.bin', lfs: { size: BODY.length, oid: SHA } }]) : new Response(BODY),
    ask: async (q) => { asked.push(q); return (over.answers || []).shift() ?? ''; },
    log: (s) => out.push(s), err: (s) => out.push(`ERR ${s}`), write: () => {},
    cmd: (s) => `agent007 ${s}`,
    ...over,
  };
  delete ctx.present; delete ctx.answers; delete ctx.answer;
  return ctx;
}
const text = (c) => c.out.join('\n');
const models = (c) => existsSync(join(c.cfg, 'whisper')) ? readdirSync(join(c.cfg, 'whisper')) : [];

describe('setupVoice', () => {
  it('macOS: brew, download after confirming, .env line, verify', async () => {
    const c = fakeCtx({ present: ['brew', 'say'], answers: ['', ''] });
    expect(await setupVoice(c)).toBe(0);
    expect(c.calls[0]).toBe('brew install whisper-cpp ffmpeg');
    expect(c.asked[1]).toMatch(/~150 MB.*huggingface\.co\/ggerganov\/whisper\.cpp/);
    expect(models(c)).toEqual(['ggml-base.en.bin']);
    expect(readFileSync(join(c.cfg, '.env'), 'utf8')).toContain(`\nWHISPER_MODEL=${join(c.cfg, 'whisper', 'ggml-base.en.bin')}\n`);
    expect(c.calls.some(x => x.startsWith('say '))).toBe(true);
    expect(text(c)).toMatch(/heard: "hello there"/);
    removeTempDir(c.home);
  });

  it('skips what is already done and asks nothing', async () => {
    const c = fakeCtx({ present: ['brew', 'whisper-cli', 'ffmpeg'] });
    mkdirSync(join(c.cfg, 'whisper'), { recursive: true });
    writeFileSync(join(c.cfg, 'whisper', 'ggml-base.en.bin'), BODY);
    writeFileSync(join(c.cfg, '.env'), `WHISPER_MODEL=${join(c.cfg, 'whisper', 'ggml-base.en.bin')}\n`);
    expect(await setupVoice(c)).toBe(0);
    expect(c.asked).toEqual([]);
    expect(c.calls.some(x => x.startsWith('brew'))).toBe(false);
    expect(text(c)).toMatch(/are installed[\s\S]*already there[\s\S]*already set/);
    removeTempDir(c.home);
  });

  it('declining the download keeps nothing; no TTY without --yes does not download', async () => {
    const no = fakeCtx({ present: ['whisper-cli', 'ffmpeg'], answers: ['', 'n'] });
    expect(await setupVoice(no)).toBe(1);
    expect(models(no)).toEqual([]);
    const quiet = fakeCtx({ present: ['whisper-cli', 'ffmpeg'], tty: false });
    expect(await setupVoice(quiet)).toBe(1);
    expect(quiet.asked).toEqual([]);
    expect(models(quiet)).toEqual([]);
    removeTempDir(no.home); removeTempDir(quiet.home);
  });

  it('--yes takes the default model without asking', async () => {
    const c = fakeCtx({ present: ['whisper-cli', 'ffmpeg'], yes: true });
    expect(await setupVoice(c)).toBe(0);
    expect(c.asked).toEqual([]);
    expect(models(c)).toEqual(['ggml-base.en.bin']);
    removeTempDir(c.home);
  });

  it('a failed or corrupt download leaves no partial file', async () => {
    for (const fetch of [
      async (u) => String(u).includes('/api/') ? Response.json([]) : new Response('x', { status: 503 }),
      async (u) => String(u).includes('/api/') ? Response.json([{ path: 'ggml-base.en.bin', lfs: { size: 3, oid: 'bad' } }]) : new Response('abc'),
      async (u) => String(u).includes('/api/') ? Response.json([{ path: 'ggml-base.en.bin', lfs: { size: 99, oid: SHA } }]) : new Response(BODY),
    ]) {
      const c = fakeCtx({ present: ['whisper-cli', 'ffmpeg'], yes: true, fetch });
      expect(await setupVoice(c)).toBe(1);
      expect(text(c)).toMatch(/✗ Download failed/);
      expect(models(c)).toEqual([]);
      expect(existsSync(join(c.cfg, '.env'))).toBe(false);
      removeTempDir(c.home);
    }
  });

  it('macOS without Homebrew prints the link and installs nothing', async () => {
    const c = fakeCtx({ yes: true });
    await setupVoice(c);
    expect(text(c)).toContain('https://brew.sh');
    expect(c.calls.some(x => x.startsWith('brew'))).toBe(false);
    removeTempDir(c.home);
  });

  it('Linux and Windows print instructions and install nothing', async () => {
    const lin = fakeCtx({ platform: 'linux', yes: true });
    expect(await setupVoice(lin)).toBe(1);
    expect(text(lin)).toMatch(/git clone[\s\S]*cmake[\s\S]*apt install ffmpeg[\s\S]*dnf install ffmpeg/);
    const win = fakeCtx({ platform: 'win32', yes: true });
    await setupVoice(win);
    expect(text(win)).toMatch(/releases[\s\S]*winget install ffmpeg/);
    expect(lin.calls.concat(win.calls)).toEqual([]);
    removeTempDir(lin.home); removeTempDir(win.home);
  });

  it('offers a restart when the server runs', async () => {
    let restarted = 0;
    const c = fakeCtx({ present: ['whisper-cli', 'ffmpeg'], answers: ['', '', 'y'] });
    await setupVoice(c, { running: async () => true, restart: async () => { restarted++; } });
    expect(c.asked.at(-1)).toMatch(/restart it now/);
    expect(restarted).toBe(1);
    const n = fakeCtx({ present: ['whisper-cli', 'ffmpeg'], answers: ['', '', 'n'] });
    await setupVoice(n, { running: async () => true, restart: async () => { restarted++; } });
    expect(restarted).toBe(1);
    expect(text(n)).toContain('agent007 restart');
    removeTempDir(c.home); removeTempDir(n.home);
  });
});

describe('setEnvLine', () => {
  it('edits only the WHISPER_MODEL line', () => {
    const dir = mkdtempSync(join(tmpdir(), 'a007-env-'));
    const f = join(dir, '.env');
    writeFileSync(f, 'PORT=8000\n# WHISPER_MODEL=/old\nHOST=0.0.0.0\n');
    expect(setEnvLine(f, 'WHISPER_MODEL', '/m.bin')).toBe(true);
    expect(readFileSync(f, 'utf8')).toBe('PORT=8000\nWHISPER_MODEL=/m.bin\nHOST=0.0.0.0\n');
    writeFileSync(f, 'A=1\nWHISPER_MODEL=/old\nB=2');
    setEnvLine(f, 'WHISPER_MODEL', '/m.bin');
    expect(readFileSync(f, 'utf8')).toBe('A=1\nWHISPER_MODEL=/m.bin\nB=2');
    writeFileSync(f, 'A=1');
    setEnvLine(f, 'WHISPER_MODEL', '/m.bin');
    expect(readFileSync(f, 'utf8')).toBe('A=1\nWHISPER_MODEL=/m.bin\n');
    removeTempDir(dir);
  });

  it('creates the file from the template', () => {
    const dir = mkdtempSync(join(tmpdir(), 'a007-env-'));
    const f = join(dir, 'sub', '.env');
    setEnvLine(f, 'WHISPER_MODEL', '/m.bin');
    const t = readFileSync(f, 'utf8');
    expect(t).toContain('\nWHISPER_MODEL=/m.bin\n');
    expect(t).toContain('# WHISPER_CPP_BIN=');
    removeTempDir(dir);
  });
});

describe('install modes', () => {
  const base = (over) => fakeCtx({ present: ['whisper-cli', 'ffmpeg'], root: '/opt/app', bin: '/opt/app/bin/agent-007.js', execPath: '/opt/node', port: 7007, host: '127.0.0.1',
    uid: 501, user: 'ada', portState: async () => 'free', readServer: () => null, callServer: async () => null, sleep: async () => {}, now: (() => { let t = 0; return () => (t += 1000); })(), version: () => '1', tailscaleBin: () => null, ...over });

  it('--voice never touches the service', async () => {
    const c = base({ yes: false });
    const code = await runCommand('install', { voice: true, yes: true }, c);
    expect(code).toBe(0);
    expect(c.calls.filter(x => /launchctl|systemctl|loginctl/.test(x))).toEqual([]);
    expect(existsSync(join(c.home, 'Library', 'LaunchAgents'))).toBe(false);
    expect(existsSync(join(c.home, '.config', 'systemd'))).toBe(false);
    removeTempDir(c.home);
  });

  it('--voice and --all together are refused', async () => {
    const c = base();
    expect(await runCommand('install', { voice: true, all: true }, c)).toBe(2);
    removeTempDir(c.home);
  });

  it('plain install asks only with a TTY; "n" or no TTY installs no voice', async () => {
    const quiet = base({ platform: 'linux', tty: false, run: async (cmd, args) => { quiet.calls.push([cmd, ...args].join(' ')); return { code: 0, stdout: '', stderr: '' }; } });
    await runCommand('install', {}, quiet);
    expect(quiet.asked).toEqual([]);
    const no = base({ platform: 'linux', answers: ['n'] });
    await runCommand('install', {}, no);
    expect(no.asked).toHaveLength(1);
    expect(no.asked[0]).toBe('Set up voice too? Downloads whisper.cpp and a ~150 MB speech model. [y/N] ');
    expect(models(no)).toEqual([]);
    removeTempDir(quiet.home); removeTempDir(no.home);
  });

  it('plain install: "y" sets voice up; --all does both without the question', async () => {
    const y = base({ platform: 'linux', answers: ['y', '', ''] });
    await runCommand('install', {}, y);
    expect(models(y)).toEqual(['ggml-base.en.bin']);
    const all = base({ platform: 'linux' });
    await runCommand('install', { all: true, yes: true }, all);
    expect(all.asked).toEqual([]);
    expect(models(all)).toEqual(['ggml-base.en.bin']);
    expect(all.calls.some(x => x.startsWith('systemctl'))).toBe(true);
    // --all goes on to remote access, which says what to do without Tailscale.
    expect(text(all)).toMatch(/Tailscale is not installed/);
    removeTempDir(y.home); removeTempDir(all.home);
  });

  it('plain install offers remote access only where Tailscale is installed', async () => {
    const ts = base({ platform: 'linux', answers: ['n', 'n'], tailscaleBin: () => '/usr/bin/tailscale' });
    await runCommand('install', {}, ts);
    expect(ts.asked[1]).toMatch(/^Set up remote access with Tailscale too\? .*\[y\/N\] $/);
    expect(ts.calls.some(x => x.includes('tailscale'))).toBe(false);
    const none = base({ platform: 'linux', answers: ['n', 'n'] });
    await runCommand('install', {}, none);
    expect(none.asked).toHaveLength(1);
    removeTempDir(ts.home); removeTempDir(none.home);
  });
});