test/branch-sync.test.js
Rama main · réplica de lectura del repositorio original.
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);
});