Ctrl K
ComunidadGuías…

test/jobs.test.js

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

Archivo original ↗Preguntar a KiBot
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);
  });
});