Ctrl K
ComunidadGuías…

test/branch-sync.test.js

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

Archivo original ↗Preguntar a KiBot
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { startTreeScanLoop, gitExec, SCAN_INTERVAL_MS } from '../server/git.js';
import { sessions } from '../server/state.js';
import { mkdtempSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { removeTempDir } from './temp-dir.js';

// Regression: a `git checkout -b` inside an agent's worktree must be reflected in
// the branch shown in the UI *without* a manual tree refresh. The branch is polled
// on every file-tree scan cycle (DESIGN.md "Branch sync"), so the scan must recur —
// it previously ran only once at spawn, so a new branch was never picked up.
//
// The scan loop has an idle gate: it only scans on cycles where the PTY produced
// new output (session.lastOutputAt advanced) since the last scan, so idle sessions
// cost zero git calls. Terminal activity is simulated here by bumping lastOutputAt.
//
// Waits are on observed events, not fixed sleeps: git is slow on Windows runners,
// and the next tick is only scheduled SCAN_INTERVAL_MS after the previous scan
// *finishes*, so a fixed sleep can end before the scan it was waiting for.
const WAIT = { timeout: 10000, interval: 50 };
// The first scan always ends by broadcasting conflicts-update (lastTreeHash starts null).
const firstScanDone = (events) => vi.waitFor(() => {
  if (!events.some(e => e.type === 'conflicts-update')) throw new Error('first scan still running');
}, WAIT);
describe('branch sync polls on the recurring scan loop', () => {
  let base, repo, wt, session;

  beforeEach(async () => {
    base = mkdtempSync(join(tmpdir(), 'a007-bsync-'));
    repo = join(base, 'repo');
    await gitExec(['init', '-q', repo]);
    await gitExec(['-C', repo, 'config', 'user.name', 'bill-slung']);
    await gitExec(['-C', repo, 'config', 'user.email', 't@t.com']);
    await gitExec(['-C', repo, 'commit', '-q', '--allow-empty', '-m', 'init']);
    await gitExec(['-C', repo, 'branch', '-M', 'main']);
    wt = join(base, 'wt');
    await gitExec(['-C', repo, 'worktree', 'add', '-q', wt, '-b', 'bill-slung/negroni']);

    session = {
      id: 'sess-bsync', repoPath: repo, worktreePath: wt,
      branchName: 'bill-slung/negroni', lastTreeHash: null, exited: false, scanTimer: null,
      lastOutputAt: 0, scannedOnce: false, lastScanOutputAt: null,
    };
    sessions.set(session.id, session);
  });

  afterEach(() => {
    session.exited = true;
    clearTimeout(session.scanTimer);
    sessions.delete(session.id);
    removeTempDir(base);
  });

  it('emits branch-changed and updates session.branchName after a checkout -b', async () => {
    const events = [];
    startTreeScanLoop(session, (msg) => events.push(msg));

    // Let the first scan cycle run *before* the branch changes, so only a
    // *recurring* loop can catch what follows — a one-shot scan would have
    // already fired against the original branch and never run again.
    await firstScanDone(events);
    expect(events.filter(e => e.type === 'branch-changed')).toHaveLength(0);

    // Create a new branch the way an agent would from its terminal — the command
    // prints to the PTY, so simulate that output bump so the idle gate opens.
    await gitExec(['-C', wt, 'checkout', '-q', '-b', 'bill-slung/martini']);
    session.lastOutputAt = Date.now();

    // A later scan cycle must pick it up — no manual refresh is triggered.
    await vi.waitFor(() => {
      expect(events.filter(e => e.type === 'branch-changed').at(-1)).toMatchObject({
        sessionId: session.id, branchName: 'bill-slung/martini',
      });
    }, WAIT);
    expect(session.branchName).toBe('bill-slung/martini');
  }, 20000);

  it('idle gate: skips the git scan while the terminal is idle, resumes on activity', async () => {
    const events = [];
    startTreeScanLoop(session, (msg) => events.push(msg));

    // First cycle scans unconditionally (sees negroni, no change event).
    await firstScanDone(events);
    expect(events.filter(e => e.type === 'branch-changed')).toHaveLength(0);

    // Change the branch WITHOUT any terminal output (lastOutputAt stays put).
    // The gate must skip these cycles, so the change is NOT observed yet.
    await gitExec(['-C', wt, 'checkout', '-q', '-b', 'bill-slung/martini']);
    await new Promise(r => setTimeout(r, SCAN_INTERVAL_MS * 2 + 300));
    expect(events.filter(e => e.type === 'branch-changed')).toHaveLength(0);
    expect(session.branchName).toBe('bill-slung/negroni');

    // Now simulate terminal activity — the very next cycle scans and catches up.
    session.lastOutputAt = Date.now();
    await vi.waitFor(() => {
      expect(events.filter(e => e.type === 'branch-changed').at(-1)).toMatchObject({
        branchName: 'bill-slung/martini',
      });
    }, WAIT);
    expect(session.branchName).toBe('bill-slung/martini');
  }, 25000);
});