test/jobs.test.js
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect } from 'vitest';
import {
createJob, countInFlightByRepo, selectDispatchableJobs, branchSlugFromTitle,
buildJobPrompt, buildJobCommand, deriveJobStatus, parsePrList,
JOB_STATES, MAX_TITLE_LEN, STALLED_AFTER_MS, DEFAULT_PERMISSION_MODE,
MAX_BRANCH_SLUG_LEN, DISPATCH_INTERVAL_MS, PERMISSION_MODES, isValidPermissionMode,
parseMergedPr, openPrListArgs, mergedPrListArgs,
} from '../lib/jobs.js';
import { parseCommand, detectState } from '../lib/helpers.js';
const REPO_A = '/repos/alpha';
const REPO_B = '/repos/beta';
function job(overrides = {}) {
const { job: j } = createJob({ title: 'A job', repoPath: REPO_A, ...overrides });
return { ...j, ...overrides };
}
// --- createJob ---
describe('createJob', () => {
it('starts a job in todo with posting attribution', () => {
const { job: j } = createJob({
title: 'Add rate limiting', detail: 'Use a token bucket.',
repoPath: REPO_A, postedBy: 'u_1', postedByName: 'Bill',
});
expect(j.state).toBe('todo');
expect(j.title).toBe('Add rate limiting');
expect(j.detail).toBe('Use a token bucket.');
expect(j.repoPath).toBe(REPO_A);
expect(j.postedBy).toBe('u_1');
expect(j.postedByName).toBe('Bill');
expect(Date.parse(j.postedAt)).not.toBeNaN();
// Nothing is assigned until the dispatcher picks it up.
expect(j.agentSessionId).toBeNull();
expect(j.agentName).toBeNull();
expect(j.startedAt).toBeNull();
// Nor finished.
expect(j.prMergedAt).toBeNull();
expect(j.doneAt).toBeNull();
});
it('rejects a job with no title or no repo', () => {
expect(createJob({ title: ' ', repoPath: REPO_A }).error).toMatch(/title/i);
expect(createJob({ title: 'x', repoPath: '' }).error).toMatch(/repos/i);
});
it('truncates an over-long title', () => {
const { job: j } = createJob({ title: 'x'.repeat(500), repoPath: REPO_A });
expect(j.title.length).toBe(MAX_TITLE_LEN);
});
it('gives each job a distinct id', () => {
const ids = new Set(Array.from({ length: 50 }, () => createJob({ title: 't', repoPath: REPO_A }).job.id));
expect(ids.size).toBe(50);
});
});
// --- Cap accounting ---
describe('countInFlightByRepo', () => {
it('counts only in-progress jobs, per repo', () => {
const jobs = [
{ state: 'in-progress', repoPath: REPO_A, agentSessionId: 's1' },
{ state: 'in-progress', repoPath: REPO_A, agentSessionId: 's2' },
{ state: 'in-progress', repoPath: REPO_B, agentSessionId: 's3' },
{ state: 'todo', repoPath: REPO_A },
{ state: 'review', repoPath: REPO_A, agentSessionId: 's4' },
];
const counts = countInFlightByRepo(jobs);
expect(counts.get(REPO_A)).toBe(2);
expect(counts.get(REPO_B)).toBe(1);
});
it('does not count a job whose agent has died', () => {
// A crashed agent must not hold a slot forever, because cards are never
// auto-reverted to To do. Without this the repo deadlocks.
const jobs = [
{ state: 'in-progress', repoPath: REPO_A, agentSessionId: 'dead' },
{ state: 'in-progress', repoPath: REPO_A, agentSessionId: 'alive' },
];
expect(countInFlightByRepo(jobs, new Set(['alive'])).get(REPO_A)).toBe(1);
});
});
// --- Dispatch selection ---
describe('selectDispatchableJobs', () => {
const mk = (state, repoPath, postedAt, extra = {}) => ({ state, repoPath, postedAt, ...extra });
it('takes todo jobs oldest first', () => {
const jobs = [
mk('todo', REPO_A, '2026-01-03T00:00:00Z', { id: 'c' }),
mk('todo', REPO_A, '2026-01-01T00:00:00Z', { id: 'a' }),
mk('todo', REPO_A, '2026-01-02T00:00:00Z', { id: 'b' }),
];
const picked = selectDispatchableJobs(jobs, { maxPerRepo: 3 });
expect(picked.map(j => j.id)).toEqual(['a', 'b', 'c']);
});
it('respects the per-repo cap, counting jobs it selects in this same pass', () => {
const jobs = [
mk('todo', REPO_A, '2026-01-01T00:00:00Z', { id: 'a1' }),
mk('todo', REPO_A, '2026-01-02T00:00:00Z', { id: 'a2' }),
mk('todo', REPO_A, '2026-01-03T00:00:00Z', { id: 'a3' }),
];
expect(selectDispatchableJobs(jobs, { maxPerRepo: 2 }).map(j => j.id)).toEqual(['a1', 'a2']);
});
it('counts already-running jobs against the cap', () => {
const jobs = [
mk('in-progress', REPO_A, '2026-01-01T00:00:00Z', { agentSessionId: 's1' }),
mk('todo', REPO_A, '2026-01-02T00:00:00Z', { id: 'a2' }),
mk('todo', REPO_A, '2026-01-03T00:00:00Z', { id: 'a3' }),
];
expect(selectDispatchableJobs(jobs, { maxPerRepo: 2, liveSessionIds: new Set(['s1']) }).map(j => j.id))
.toEqual(['a2']);
});
it('caps each repo independently', () => {
const jobs = [
mk('todo', REPO_A, '2026-01-01T00:00:00Z', { id: 'a1' }),
mk('todo', REPO_B, '2026-01-02T00:00:00Z', { id: 'b1' }),
mk('todo', REPO_A, '2026-01-03T00:00:00Z', { id: 'a2' }),
];
expect(selectDispatchableJobs(jobs, { maxPerRepo: 1 }).map(j => j.id)).toEqual(['a1', 'b1']);
});
it('leaves a job queued when its repo is unavailable', () => {
const jobs = [
mk('todo', REPO_A, '2026-01-01T00:00:00Z', { id: 'a1' }),
mk('todo', REPO_B, '2026-01-02T00:00:00Z', { id: 'b1' }),
];
const picked = selectDispatchableJobs(jobs, { maxPerRepo: 5, availableRepos: new Set([REPO_B]) });
expect(picked.map(j => j.id)).toEqual(['b1']);
});
it('never selects a job that is not in todo', () => {
const jobs = [
mk('in-progress', REPO_A, '2026-01-01T00:00:00Z', { agentSessionId: 's1' }),
mk('review', REPO_A, '2026-01-02T00:00:00Z'),
];
expect(selectDispatchableJobs(jobs, { maxPerRepo: 5 })).toEqual([]);
});
});
// --- Prompt delivery ---
describe('buildJobCommand', () => {
it('round-trips the prompt through parseCommand as a single argv', () => {
const j = job({ title: 'Fix "quoted" bug', detail: 'Windows path C:\\temp\nand $HOME' });
const parsed = parseCommand(buildJobCommand(j));
expect(parsed.file).toBe('claude');
expect(parsed.args[0]).toBe('--permission-mode');
expect(parsed.args[1]).toBe(DEFAULT_PERMISSION_MODE);
// The whole prompt is ONE argument: it can never be re-split into flags,
// and no shell sees it, so $HOME is not expanded.
expect(parsed.args).toHaveLength(3);
expect(parsed.args[2]).toBe(buildJobPrompt(j));
expect(parsed.args[2]).toContain('Fix "quoted" bug');
expect(parsed.args[2]).toContain('C:\\temp');
expect(parsed.args[2]).toContain('$HOME');
});
it('honours an overridden permission mode', () => {
// Deliberately NOT the default -- this assertion has to be able to fail.
// It used to pass 'bypassPermissions', which became the default and turned
// the test into a tautology.
const parsed = parseCommand(buildJobCommand(job(), { permissionMode: 'acceptEdits' }));
expect(parsed.args[1]).toBe('acceptEdits');
expect(parsed.args[1]).not.toBe(DEFAULT_PERMISSION_MODE);
});
it('includes the title, the detail, and the ship instruction', () => {
const prompt = buildJobPrompt(job({ title: 'T', detail: 'D' }));
expect(prompt.startsWith('T')).toBe(true);
expect(prompt).toContain('D');
expect(prompt).toContain('/ship');
});
it('handles a job with no detail', () => {
const prompt = buildJobPrompt(job({ title: 'Only a title', detail: '' }));
expect(prompt).toContain('Only a title');
expect(prompt).toContain('/ship');
});
it('lists attached files by absolute path, before the ship instruction', () => {
const prompt = buildJobPrompt(job({ attachments: [{ name: 'shot.png', path: '/cfg/attachments/j1/shot.png' }] }));
expect(prompt).toMatch(/Attached files[^\n]*\n \/cfg\/attachments\/j1\/shot\.png/);
expect(prompt.indexOf('shot.png')).toBeLessThan(prompt.indexOf('/ship'));
expect(buildJobPrompt(job({ attachments: [] }))).not.toContain('Attached files');
});
it('grants the agent the attachments dir with --add-dir, after the prompt', () => {
const parsed = parseCommand(buildJobCommand(job({ attachments: [{ name: 'a.png', path: '/cfg/attachments/j1/a.png' }, { name: 'b.png', path: '/cfg/attachments/j1/b.png' }] })));
expect(parsed.args.slice(3)).toEqual(['--add-dir', '/cfg/attachments/j1']);
expect(parseCommand(buildJobCommand(job())).args).toHaveLength(3);
});
});
// --- Live status ---
describe('deriveJobStatus', () => {
const now = 1_000_000_000;
const inProgress = { state: 'in-progress' };
it('is null for jobs that are not in progress', () => {
expect(deriveJobStatus({ state: 'todo' }, null)).toBeNull();
expect(deriveJobStatus({ state: 'review' }, { state: 'WAITING' })).toBeNull();
});
it('reports a missing or exited agent as gone', () => {
expect(deriveJobStatus(inProgress, null)).toBe('gone');
expect(deriveJobStatus(inProgress, { state: 'WAITING', exited: true })).toBe('gone');
});
it('reports MESSAGE as needs-input', () => {
expect(deriveJobStatus(inProgress, { state: 'MESSAGE', lastOutputAt: now }, { now })).toBe('needs-input');
});
it('reports a busy agent as running', () => {
expect(deriveJobStatus(inProgress, { state: 'WORKING', lastOutputAt: now }, { now })).toBe('running');
});
it('only calls a WAITING agent stalled once it has been quiet past the window', () => {
const fresh = { state: 'WAITING', lastOutputAt: now - 1000 };
expect(deriveJobStatus(inProgress, fresh, { now })).toBe('running');
const quiet = { state: 'WAITING', lastOutputAt: now - STALLED_AFTER_MS - 1 };
expect(deriveJobStatus(inProgress, quiet, { now })).toBe('stalled');
});
it('treats MESSAGE as needs-input regardless of how long it has been quiet', () => {
const old = { state: 'MESSAGE', lastOutputAt: now - 10 * STALLED_AFTER_MS };
expect(deriveJobStatus(inProgress, old, { now })).toBe('needs-input');
});
});
// --- PR detection ---
describe('parsePrList', () => {
it('returns the open PR', () => {
const out = JSON.stringify([{ number: 42, url: 'https://github.com/o/r/pull/42', state: 'OPEN', isDraft: false }]);
expect(parsePrList(out)).toEqual({ url: 'https://github.com/o/r/pull/42', number: 42, isDraft: false });
});
it('counts a draft PR as open', () => {
const out = JSON.stringify([{ number: 7, url: 'u', state: 'OPEN', isDraft: true }]);
expect(parsePrList(out).isDraft).toBe(true);
});
it('ignores closed and merged PRs', () => {
const out = JSON.stringify([
{ number: 1, url: 'a', state: 'CLOSED' },
{ number: 2, url: 'b', state: 'MERGED' },
]);
expect(parsePrList(out)).toBeNull();
});
it('returns null for no PRs, malformed JSON, or a non-array', () => {
expect(parsePrList('[]')).toBeNull();
expect(parsePrList('not json')).toBeNull();
expect(parsePrList('{"a":1}')).toBeNull();
expect(parsePrList('')).toBeNull();
});
});
describe('parseMergedPr', () => {
it('returns the merged PR with when it merged', () => {
const out = JSON.stringify([
{ number: 42, url: 'https://github.com/o/r/pull/42', state: 'MERGED', mergedAt: '2026-08-28T10:00:00Z' },
]);
expect(parseMergedPr(out)).toEqual({
url: 'https://github.com/o/r/pull/42', number: 42, mergedAt: '2026-08-28T10:00:00Z',
});
});
it('does NOT treat a PR closed without merging as merged', () => {
// The whole point of the field check: nothing landed, so somebody still has
// to decide what happens to the work. Its card must stay on the board.
const out = JSON.stringify([{ number: 9, url: 'u', state: 'CLOSED', mergedAt: null }]);
expect(parseMergedPr(out)).toBeNull();
});
it('accepts a merged PR that reports only one of state or mergedAt', () => {
expect(parseMergedPr(JSON.stringify([{ number: 1, url: 'a', state: 'MERGED' }])).number).toBe(1);
expect(parseMergedPr(JSON.stringify([{ number: 2, url: 'b', mergedAt: '2026-01-01T00:00:00Z' }])).number).toBe(2);
});
it('returns null for no PRs, malformed JSON, or a non-array', () => {
expect(parseMergedPr('[]')).toBeNull();
expect(parseMergedPr('not json')).toBeNull();
expect(parseMergedPr('{"a":1}')).toBeNull();
expect(parseMergedPr('')).toBeNull();
});
// --- Which merge belongs to THIS card ---
//
// `gh pr list --head` matches the head ref NAME, and that name outlives the
// branch: a merged PR stays in the listing forever, and board branch names are
// reused once their branch is deleted. Without these checks a stale merge
// files away a card whose real PR is still open, taking its number with it.
const TWO_PRS = JSON.stringify([
{ number: 1, url: 'https://gh/o/r/pull/1', state: 'MERGED', mergedAt: '2026-08-01T10:00:00Z' },
{ number: 2, url: 'https://gh/o/r/pull/2', state: 'MERGED', mergedAt: '2026-08-28T10:00:00Z' },
]);
it('returns the card\'s own PR, not just the first merge on the branch', () => {
expect(parseMergedPr(TWO_PRS, { number: 2 }).number).toBe(2);
expect(parseMergedPr(TWO_PRS, { number: 1 }).number).toBe(1);
});
it('refuses a merge on the branch that is not the card\'s PR', () => {
// The card is waiting on #7; #1 merging says nothing about it.
const out = JSON.stringify([{ number: 1, url: 'u', state: 'MERGED', mergedAt: '2026-08-01T10:00:00Z' }]);
expect(parseMergedPr(out, { number: 7 })).toBeNull();
});
it('with no PR of record, ignores a merge older than this attempt', () => {
// The branch name was reused: that merge belongs to whatever had it before.
expect(parseMergedPr(TWO_PRS, { mergedAfter: '2026-08-20T00:00:00Z' }).number).toBe(2);
expect(parseMergedPr(TWO_PRS, { mergedAfter: '2026-09-01T00:00:00Z' })).toBeNull();
});
it('with no PR of record, refuses a merge it cannot place in time', () => {
// Rejecting leaves the card on the board, which is the safe direction.
const out = JSON.stringify([{ number: 3, url: 'u', state: 'MERGED' }]);
expect(parseMergedPr(out, { mergedAfter: '2026-08-01T00:00:00Z' })).toBeNull();
// With nothing to compare against, it is accepted.
expect(parseMergedPr(out).number).toBe(3);
expect(parseMergedPr(out, { mergedAfter: 'not a date' }).number).toBe(3);
});
it('never lets a closed PR through either matcher', () => {
const out = JSON.stringify([{ number: 9, url: 'u', state: 'CLOSED', mergedAt: null }]);
expect(parseMergedPr(out, { number: 9 })).toBeNull();
expect(parseMergedPr(out, { mergedAfter: '2020-01-01T00:00:00Z' })).toBeNull();
});
});
describe('JOB_STATES', () => {
it('is the three board columns plus done, in workflow order', () => {
expect(JOB_STATES).toEqual(['todo', 'in-progress', 'review', 'done']);
});
});
describe('gh pr list queries', () => {
// These pin the one flag that decides whether a card stays on the board.
it('asks for OPEN pull requests on the branch, with the fields parsePrList reads', () => {
expect(openPrListArgs('bill/x')).toEqual(
['pr', 'list', '--head', 'bill/x', '--state', 'open', '--json', 'number,url,state,isDraft,isCrossRepository']);
});
it('asks for MERGED pull requests on the branch, with the fields parseMergedPr reads', () => {
// --state merged, never closed: a PR closed without merging must not take
// its card off the board.
expect(mergedPrListArgs('bill/x')).toEqual(
['pr', 'list', '--head', 'bill/x', '--state', 'merged', '--json', 'number,url,state,mergedAt']);
});
it('requests every field its parser reads', () => {
const fields = (args) => args[args.indexOf('--json') + 1].split(',');
expect(fields(openPrListArgs('b'))).toEqual(expect.arrayContaining(['number', 'url', 'state', 'isDraft']));
expect(fields(mergedPrListArgs('b'))).toEqual(expect.arrayContaining(['number', 'url', 'state', 'mergedAt']));
});
});
// --- Workspace-trust dialog detection ---
//
// These lines are copied verbatim from a real PTY capture of `claude` starting
// in a fresh git worktree, after stripAnsiComplete. The TUI positions words
// with cursor moves, so they arrive with no spaces between them — which is why
// the patterns use \\s* rather than literal spaces.
describe('workspace-trust dialog is detected as needing the user', () => {
const CAPTURED = [
'Securityguide',
'\u276fNo,exit',
'Yes,Itrustthisfolder',
'Entertoconfirm\u00b7Esctocancel',
'0q',
];
const session = (over = {}) => ({
exited: false, isTUI: true,
lastOutputAt: Date.now() - 30_000, // long past the WORKING window
lastStrippedLine: '0q',
recentStrippedLines: CAPTURED,
...over,
});
it('reports MESSAGE, not WAITING, for the trust dialog', () => {
// Every board agent starts in a brand-new worktree and meets this dialog,
// so without detection every dispatched job would look like it was working.
expect(detectState(session())).toBe('MESSAGE');
});
it('feeds through to the job board as needs-input', () => {
const s = session({ state: 'MESSAGE' });
expect(deriveJobStatus({ state: 'in-progress' }, s)).toBe('needs-input');
});
it('still reports WAITING for an agent merely parked at its prompt', () => {
expect(detectState(session({
lastStrippedLine: '',
recentStrippedLines: ['Doneandcommitted.', 'Anythingelse?'],
}))).toBe('WAITING');
});
it('matches the generic confirm footer even with normal spacing', () => {
expect(detectState(session({
lastStrippedLine: 'Enter to confirm \u00b7 Esc to cancel',
recentStrippedLines: ['Enter to confirm \u00b7 Esc to cancel'],
}))).toBe('MESSAGE');
});
});
// --- Branch naming ---
describe('branchSlugFromTitle', () => {
it('reads like the job title', () => {
expect(branchSlugFromTitle('Add rate limiting')).toBe('add-rate-limiting');
expect(branchSlugFromTitle('UPPER Case-Thing')).toBe('upper-case-thing');
});
it('strips punctuation that git refs forbid', () => {
// ~ ^ : ? * [ .. and a leading dash are all illegal in a ref name; keeping
// to [a-z0-9-] side-steps the whole rule set rather than enumerating it.
for (const title of ['Fix ~weird^ name:?', 'a..b', '--leading', 'trailing--', 'x[0]*y']) {
const slug = branchSlugFromTitle(title);
expect(slug).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
}
});
it('bounds the length and never ends in a dash', () => {
const slug = branchSlugFromTitle('word '.repeat(40));
expect(slug.length).toBeLessThanOrEqual(MAX_BRANCH_SLUG_LEN);
expect(slug.endsWith('-')).toBe(false);
});
it('falls back rather than producing an empty ref', () => {
// A title of pure punctuation or non-Latin script would otherwise slug to
// '' and make `git worktree add -b <gituser>/` fail.
expect(branchSlugFromTitle('———')).toBe('job');
expect(branchSlugFromTitle('')).toBe('job');
expect(branchSlugFromTitle(' ')).toBe('job');
expect(branchSlugFromTitle(null)).toBe('job');
});
});
// --- Dispatch defaults ---
describe('dispatch defaults', () => {
it('scans every 5 minutes', () => {
expect(DISPATCH_INTERVAL_MS).toBe(5 * 60 * 1000);
});
it('runs agents in auto mode', () => {
// Back to auto: its classifier is the only thing that reviews what a
// dispatched agent does before it does it, which matters most for a job
// whose prompt is card text plus repo content. Where auto is unavailable
// the board setting and the per-card override are the way out -- see
// test/jobs-permission-mode.test.js.
expect(DEFAULT_PERMISSION_MODE).toBe('auto');
expect(PERMISSION_MODES).toContain(DEFAULT_PERMISSION_MODE);
});
it('sends the agent to /ship when the work is finished', () => {
const prompt = buildJobPrompt({ title: 'T', detail: 'D' });
expect(prompt).toMatch(/when the work is finished, run \/ship/i);
});
it('forecloses running anything ahead of /ship', () => {
// /ship already merges the base branch, tests, reviews and fix-loops. A
// pass run before it repeats that work on a tree that is about to change.
const prompt = buildJobPrompt({ title: 'T' });
expect(prompt).toMatch(/nothing else needs running first/i);
});
it('names no skill but /ship', () => {
// A dispatched agent arrives with no memory of the other skills. Naming
// one — even to forbid it — is what puts it on the table, so the prompt
// mentions /ship and nothing else.
const prompt = buildJobPrompt({ title: 'T' });
const skills = prompt.match(/\/[a-z][a-z-]*/g) || [];
expect([...new Set(skills)]).toEqual(['/ship']);
});
it('forbids ending the turn before the PR exists', () => {
// An agent finished its task and /review, then ended its turn with "Next is
// /ship" — treating the sequence as a plan to report on rather than one to
// finish. Nobody was there to say "continue", so the job stalled at a
// prompt with all its work uncommitted. The instruction has to close that
// stopping point off explicitly.
const prompt = buildJobPrompt({ title: 'T' });
expect(prompt).toMatch(/do not end your turn until \/ship/i);
// And it must name the specific failure, not just assert the rule.
expect(prompt).toMatch(/describe what you would do next/i);
});
it('still allows stopping for a real question or a hard failure', () => {
// The rule must not read as "never stop", or an agent that genuinely needs
// the user will thrash instead of asking.
const prompt = buildJobPrompt({ title: 'T' });
expect(prompt).toMatch(/question you genuinely cannot answer|failure you cannot get past/i);
});
});
// --- Permission mode is argv, so it must be an allowlist ---
describe('permission mode validation', () => {
it('accepts every mode claude actually supports', () => {
for (const mode of PERMISSION_MODES) expect(isValidPermissionMode(mode)).toBe(true);
});
it('rejects anything else', () => {
for (const bad of ['', null, undefined, 'AUTO', 'yolo', 42, {}]) {
expect(isValidPermissionMode(bad)).toBe(false);
}
});
it('cannot be used to smuggle extra flags onto the agent', () => {
// buildJobCommand interpolates this into a command string that parseCommand
// splits into argv. Unvalidated, "auto --dangerously-skip-permissions"
// becomes a second flag on every dispatched agent.
const cmd = buildJobCommand(job(), { permissionMode: 'auto --dangerously-skip-permissions' });
const parsed = parseCommand(cmd);
expect(parsed.args[1]).toBe(DEFAULT_PERMISSION_MODE);
expect(parsed.args).toHaveLength(3);
expect(cmd).not.toContain('dangerously');
});
it('falls back to the default rather than emitting an empty flag', () => {
const parsed = parseCommand(buildJobCommand(job(), { permissionMode: '' }));
expect(parsed.args[1]).toBe(DEFAULT_PERMISSION_MODE);
});
});
describe('adopting a PR from someone else\'s fork', () => {
it('never takes a cross-repository PR that shares the head ref name', () => {
expect(parsePrList(JSON.stringify([{ number: 5, url: 'u5', state: 'OPEN', isCrossRepository: true }]))).toBeNull();
expect(parsePrList(JSON.stringify([{ number: 5, url: 'u5', state: 'OPEN', isCrossRepository: true }, { number: 6, url: 'u6', state: 'OPEN' }])).number).toBe(6);
});
});