Ctrl K
ComunidadGuías…

test/self-update.test.js

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

Archivo original ↗Preguntar a KiBot
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import { mkdtempSync, writeFileSync, existsSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { updateInfo, startUpdate, resetSelfUpdate, updateLogPath, updateNotes, changelogBetween, CHANGELOG_URL } from '../server/self-update.js';
import { removeTempDir } from './temp-dir.js';
import { changelogWith } from '../scripts/release.js';

// A fake spawn: records the call, hands back a child the test ends by hand.
function fakeSpawn() {
  const calls = [];
  const spawn = (cmd, args, opts) => {
    const child = Object.assign(new EventEmitter(), { pid: 4242, unref() { child.unrefed = true; } });
    calls.push({ cmd, args, opts, child });
    return child;
  };
  return { spawn, calls };
}

let dir, env;
beforeEach(() => { resetSelfUpdate(); dir = mkdtempSync(join(tmpdir(), 'a007-selfupdate-')); env = { AGENT007_CONFIG_DIR: dir }; });
afterEach(() => removeTempDir(dir));

describe('updateInfo', () => {
  it('names a newer npm version in VERSION form, and none when current', async () => {
    expect(await updateInfo({ kind: 'npm', version: '0.53.5.0', fetchLatest: async () => '0.54.0', env })).toEqual({ version: '0.53.5.0', kind: 'npm', latest: '0.54.0.0' });
    resetSelfUpdate();
    expect((await updateInfo({ kind: 'npm', version: '0.53.5.0', fetchLatest: async () => '0.53.5000', env })).latest).toBeUndefined();
    resetSelfUpdate();
    expect((await updateInfo({ kind: 'npm', version: '0.53.5.0', fetchLatest: async () => null, env })).latest).toBeUndefined();
  });

  it('fresh skips the cache, at most every 10 s, and keeps the cache when the registry fails', async () => {
    let asked = 0, answer = '9.0.0';
    const fetchLatest = async () => { asked++; return answer; };
    await updateInfo({ kind: 'npm', version: '1.0.0.0', fetchLatest, env });
    expect(asked).toBe(1);
    expect((await updateInfo({ kind: 'npm', version: '1.0.0.0', fetchLatest, env, fresh: true })).latest).toBe('9.0.0.0');
    expect(asked).toBe(2);
    answer = '10.0.0';
    expect((await updateInfo({ kind: 'npm', version: '1.0.0.0', fetchLatest, env, fresh: true })).latest).toBe('9.0.0.0');
    expect(asked).toBe(2);
    resetSelfUpdate();
    await updateInfo({ kind: 'npm', version: '1.0.0.0', fetchLatest, env });
    answer = null;
    const failed = await updateInfo({ kind: 'npm', version: '1.0.0.0', fetchLatest, env, fresh: true });
    expect(failed.checkFailed).toBe(true);
    expect(failed.latest).toBe('10.0.0.0');
  });

  it('asks the registry once per 10 minutes', async () => {
    let asked = 0;
    const fetchLatest = async () => { asked++; return '9.0.0'; };
    await updateInfo({ kind: 'npm', fetchLatest, env });
    await updateInfo({ kind: 'checkout', fetchLatest, env });
    expect(asked).toBe(1);
  });

  it('never asks for an npx copy', async () => {
    let asked = 0;
    const info = await updateInfo({ kind: 'npx', version: '1.0.0.0', fetchLatest: async () => { asked++; return '9.0.0'; }, env });
    expect(info).toEqual({ version: '1.0.0.0', kind: 'npx' });
    expect(asked).toBe(0);
  });
});

describe('startUpdate', () => {
  it('runs `agent007 update` detached with its output in update.log, and returns at once', () => {
    const { spawn, calls } = fakeSpawn();
    expect(startUpdate({ kind: 'npm', spawn, env, execPath: '/node', bin: '/app/bin/agent-007.js' })).toEqual({ ok: true });
    expect(calls).toHaveLength(1);
    const [{ cmd, args, opts, child }] = calls;
    expect([cmd, ...args]).toEqual(['/node', '/app/bin/agent-007.js', 'update']);
    expect(opts.detached).toBe(true);
    expect(typeof opts.stdio[1]).toBe('number');
    expect(child.unrefed).toBe(true);
    expect(existsSync(updateLogPath(env))).toBe(true);
  });

  it('starts one update at a time, and another once it has ended', async () => {
    const { spawn, calls } = fakeSpawn();
    startUpdate({ kind: 'checkout', spawn, env });
    expect(startUpdate({ kind: 'checkout', spawn, env })).toEqual({ error: 'An update is already running.' });
    expect(calls).toHaveLength(1);
    expect((await updateInfo({ kind: 'checkout', fetchLatest: async () => null, env })).updating).toEqual({});
    calls[0].child.emit('exit', 0);
    expect(startUpdate({ kind: 'checkout', spawn, env })).toEqual({ ok: true });
    expect(calls).toHaveLength(2);
  });

  it('says how many busy workers it waits for, and reads a failure back from the log', async () => {
    const { spawn, calls } = fakeSpawn();
    startUpdate({ kind: 'npm', spawn, env });
    writeFileSync(updateLogPath(env), 'Updated 1 → 2.\n2 workers are mid-run. Waiting for the board to go idle...\n');
    expect((await updateInfo({ kind: 'npm', fetchLatest: async () => null, env, workers: 2 })).updating).toEqual({ waiting: 2 });
    writeFileSync(updateLogPath(env), 'npm install -g failed: EACCES: permission denied\n');
    calls[0].child.emit('exit', 1);
    expect((await updateInfo({ kind: 'npm', fetchLatest: async () => null, env })).finished).toEqual({ code: 1, log: 'npm install -g failed: EACCES: permission denied' });
  });

  it('refuses an npx copy and spawns nothing', () => {
    const { spawn, calls } = fakeSpawn();
    expect(startUpdate({ kind: 'npx', spawn, env }).error).toMatch(/npx runs the latest/);
    expect(calls).toHaveLength(0);
  });
});

const LOG = `# Changelog

## [2.1.0.0] - 2026-10-09

- unreleased

## [2.0.0.0] - 2026-10-08

### Added

- **Two.**

## [1.1.0.0] - 2026-10-07

- One one.

## [1.0.0.0] - 2026-10-06

- One.
`;

describe('updateNotes', () => {
  it('keeps only the sections newer than this version, up to the latest, newest first', () => {
    const notes = changelogBetween(LOG, '1.0.0.0', '2.0.0.0');
    expect(notes.match(/^## \[[\d.]+\]/gm)).toEqual(['## [2.0.0.0]', '## [1.1.0.0]']);
    expect(notes).toContain('- **Two.**');
    expect(changelogBetween(LOG, '2.0.0.0', '2.0.0.0')).toBe('');
  });

  it('reads the CHANGELOG at the latest release once, and slices it', async () => {
    const asked = [];
    const fetchText = async (v) => { asked.push(v); return LOG; };
    const r = await updateNotes({ kind: 'npm', version: '1.0.0.0', fetchLatest: async () => '2.0.0', fetchText });
    expect(r).toMatchObject({ version: '1.0.0.0', latest: '2.0.0.0' });
    expect(r.notes).toContain('## [1.1.0.0]');
    expect(r.notes).not.toContain('## [1.0.0.0]');
    expect(r.notes).not.toContain('unreleased');
    await updateNotes({ kind: 'npm', version: '1.0.0.0', fetchLatest: async () => '2.0.0', fetchText });
    expect(asked).toEqual(['2.0.0.0']);
  });

  it('says so, with the GitHub link, when the fetch fails, and tries again next time', async () => {
    let text = null;
    const fetchText = async () => text;
    const r = await updateNotes({ kind: 'npm', version: '1.0.0.0', fetchLatest: async () => '2.0.0', fetchText });
    expect(r.error).toMatch(/Could not load/);
    expect(r.url).toBe(CHANGELOG_URL);
    expect(r.notes).toBeUndefined();
    text = LOG;
    expect((await updateNotes({ kind: 'npm', version: '1.0.0.0', fetchLatest: async () => '2.0.0', fetchText })).notes).toContain('## [2.0.0.0]');
  });

  // Value: protects=a newer release's notes after Check for updates moves the latest on; fails_when=notesCache is reused without comparing its latest (stale notes until restart); why_new=the cache test above keeps one latest throughout; seam=none
  it('reads the CHANGELOG again once a check finds a newer latest', async () => {
    const asked = [];
    const fetchText = async (v) => { asked.push(v); return LOG; };
    await updateNotes({ kind: 'npm', version: '1.0.0.0', fetchLatest: async () => '2.0.0', fetchText });
    await updateInfo({ kind: 'npm', version: '1.0.0.0', fetchLatest: async () => '2.1.0', env, fresh: true });
    const r = await updateNotes({ kind: 'npm', version: '1.0.0.0', fetchLatest: async () => '2.1.0', fetchText });
    expect(asked).toEqual(['2.0.0.0', '2.1.0.0']);
    expect(r.notes).toContain('## [2.1.0.0]');
  });

  // Value: protects=the release script's section headings staying sliceable; fails_when=scripts/release.js changelogWith or changelogBetween's heading regex changes and they drift apart (empty What's new); why_new=other tests use a hand-written LOG, never the generator's output; seam=none
  it('slices sections the release script writes', () => {
    let log = '# Changelog\n';
    for (const v of ['1.0.0.0', '1.1.0.0', '2.0.0.0']) log = changelogWith(log, v, '2026-10-08', [`### Added\n\n- In ${v}.`]);
    const notes = changelogBetween(log, '1.0.0.0', '2.0.0.0');
    expect(notes).toContain('- In 2.0.0.0.');
    expect(notes).toContain('- In 1.1.0.0.');
    expect(notes).not.toContain('- In 1.0.0.0.');
    expect(notes.indexOf('2.0.0.0')).toBeLessThan(notes.indexOf('1.1.0.0'));
  });

  // Value: protects=windows opened together share one GitHub fetch, and npx never asks; fails_when=the in-flight promise is not cached or the npx guard goes; why_new=the other cases await one call at a time; seam=none
  it('shares one fetch between windows opened together, and asks nothing under npx', async () => {
    let asked = 0;
    const fetchText = async () => { asked++; return LOG; };
    const opts = { kind: 'npm', version: '1.0.0.0', fetchLatest: async () => '2.0.0', fetchText };
    const [a, b] = await Promise.all([updateNotes(opts), updateNotes(opts)]);
    expect(asked).toBe(1);
    expect(a.notes).toBe(b.notes);
    expect(await updateNotes({ ...opts, kind: 'npx', fetchLatest: async () => { throw new Error('not asked'); } })).toEqual({ version: '1.0.0.0', notes: '' });
  });

  it('has nothing to say when up to date', async () => {
    const fetchText = async () => { throw new Error('not asked'); };
    expect(await updateNotes({ kind: 'npm', version: '2.0.0.0', fetchLatest: async () => '2.0.0', fetchText })).toEqual({ version: '2.0.0.0', notes: '' });
  });
});