Ctrl K
ComunidadGuías…

test/client-jobs.test.js

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

Archivo original ↗Preguntar a KiBot
// @vitest-environment happy-dom
import { describe, it, expect, vi, beforeEach } from 'vitest';

vi.mock('../public/modules/ws.js', () => ({ send: vi.fn(() => true) }));
// terminal.js pulls in xterm; the board only needs switchToSession and
// updateTabs from it, and stubbing it also proves the board does not depend on
// the real module loading.
vi.mock('../public/modules/terminal.js', () => ({ switchToSession: vi.fn(), restartAgent: vi.fn(), restartPending: () => false }));

import { send } from '../public/modules/ws.js';
import { switchToSession } from '../public/modules/terminal.js';
import { agents, jobs, repos, setActiveSession } from '../public/modules/state.js';
import { handleJobsList, showJobBoard, hideJobBoard, renderBoard, setupJobBoard, openJobForm, closeJobForm } from '../public/modules/jobs.js';

// A card sent to the server. Opening the form also asks for fresh models,
// which is not a save.
const JOB_SAVE = expect.objectContaining({ type: expect.stringMatching(/^job-(create|update)$/) });

const BOARD_HTML = `
  <button id="btn-new-job-shortcut"></button>
  <div class="spawn-form" id="spawn-form" style="display:none"></div>
  <div id="terminal-viewport">
    <div id="terminal-empty"></div>
    <div class="job-board" id="job-board" style="display:none">
      <div class="job-board-toolbar">
        <button id="btn-new-job"></button>
        <button id="btn-dispatcher-toggle"></button>
        <button id="btn-dispatch-now"></button>
        <span id="job-dispatcher-status"></span>
        <input type="number" id="job-max-per-repo">
        <input type="number" id="job-max-workers">
        <select id="job-permission-mode">
          <option value="auto">auto</option>
          <option value="acceptEdits">acceptEdits</option>
          <option value="bypassPermissions">bypassPermissions</option>
          <option value="manual">manual</option>
          <option value="dontAsk">dontAsk</option>
          <option value="plan">plan</option>
        </select>
        <select id="job-deploy-repo"></select>
        <select id="job-deploy-policy">
          <option value="ask">ask</option>
          <option value="ask-production-only">prod</option>
          <option value="never-ask">never</option>
        </select>
        <button id="btn-finished-jobs"></button>
      </div>
      <div class="job-form" id="job-form" style="display:none">
        <input type="text" id="job-title">
        <select id="job-repo"></select>
        <select id="job-type">
          <option value="one-time">Now</option>
          <option value="once">Scheduled</option>
          <option value="scheduled">Recurring</option>
        </select>
        <div id="job-schedule-field" style="display:none">
          <input type="text" id="job-schedule">
        </div>
        <div id="job-run-at-field" style="display:none">
          <input type="datetime-local" id="job-run-at">
        </div>
        <select id="job-agent">
          <option value="claude">Claude Code</option>
          <option value="codex">Codex</option>
        </select>
        <select id="job-model"><option value="">CLI default</option></select>
        <label id="job-pr-field">
          <select id="job-requires-pr">
            <option value="yes">Required</option>
            <option value="no">Not required</option>
          </select>
        </label>
        <select id="job-permission-mode-field">
          <option value=""></option>
          <option value="auto">auto</option>
          <option value="acceptEdits" data-claude-only>acceptEdits</option>
          <option value="bypassPermissions">bypassPermissions</option>
          <option value="manual" data-claude-only>manual</option>
          <option value="dontAsk" data-claude-only>dontAsk</option>
          <option value="plan" data-claude-only>plan</option>
        </select>
        <textarea id="job-detail"></textarea>
        <button id="btn-job-attach"></button>
        <input type="file" id="job-attach-input">
        <div id="job-attachments"></div>
        <button id="btn-job-save"></button>
        <button id="btn-job-cancel"></button>
        <div id="job-form-error" style="display:none"></div>
      </div>
      <div class="job-columns" id="job-columns"></div>
      <div class="job-finished" id="job-finished" style="display:none">
        <span id="job-finished-count"></span>
        <div class="job-cards" id="job-finished-cards"></div>
      </div>
    </div>
  </div>`;

const JOB = (over = {}) => ({
  id: 'job-1', title: 'Add rate limiting', detail: 'Token bucket.',
  repoPath: '/repos/alpha', state: 'todo',
  postedByName: 'Bill', postedAt: new Date().toISOString(),
  agentSessionId: null, agentName: null, startedAt: null,
  branchName: null, prUrl: null, prNumber: null, lastError: null,
  prMergedAt: null, doneAt: null,
  ...over,
});

beforeEach(() => {
  document.body.innerHTML = BOARD_HTML;
  jobs.clear();
  agents.clear();
  repos.clear();
  repos.set('/repos/alpha', { slug: 'alpha', exists: true });
  setActiveSession(null);
  vi.clearAllMocks();
  setupJobBoard();
  showJobBoard();
});

function cards() {
  return [...document.querySelectorAll('.job-card')];
}
function columnCards(state) {
  return [...document.querySelectorAll(`.job-column[data-state="${state}"] .job-card`)];
}

describe('board rendering', () => {
  it('renders the three columns in workflow order', () => {
    handleJobsList({ jobs: [], settings: {} });
    const labels = [...document.querySelectorAll('.job-column')].map(c => c.dataset.state);
    expect(labels).toEqual(['todo', 'in-progress', 'review']);
  });

  it('places each job in its own column and counts them', () => {
    handleJobsList({
      jobs: [
        JOB({ id: 'a', state: 'todo' }),
        JOB({ id: 'b', state: 'in-progress', agentSessionId: 's1', agentName: 'Viper' }),
        JOB({ id: 'c', state: 'review', prUrl: 'https://x/pull/9', prNumber: 9 }),
      ],
      settings: {},
    });
    expect(columnCards('todo')).toHaveLength(1);
    expect(columnCards('in-progress')).toHaveLength(1);
    expect(columnCards('review')).toHaveLength(1);
    expect([...document.querySelectorAll('.job-column-count')].map(e => e.textContent)).toEqual(['1', '1', '1']);
  });

  it('shows title, repo, poster, agent and branch on the card', () => {
    handleJobsList({
      jobs: [JOB({
        state: 'in-progress', agentSessionId: 's1', agentName: 'Viper',
        branchName: 'bill/vesper', startedAt: new Date().toISOString(),
      })],
      settings: {},
    });
    const card = cards()[0];
    expect(card.querySelector('.job-card-title').textContent).toBe('Add rate limiting');
    expect(card.querySelector('.job-card-repo').textContent).toBe('alpha');
    expect(card.querySelector('.job-card-posted').textContent).toContain('Bill');
    expect(card.querySelector('.job-card-agent-name').textContent).toBe('Viper');
    expect(card.querySelector('.job-card-branch').textContent).toBe('bill/vesper');
  });

  it('links the PR on a review card', () => {
    handleJobsList({ jobs: [JOB({ state: 'review', prUrl: 'https://gh/o/r/pull/12', prNumber: 12 })], settings: {} });
    const link = cards()[0].querySelector('.job-card-pr');
    expect(link.textContent).toBe('PR #12');
    expect(link.getAttribute('href')).toBe('https://gh/o/r/pull/12');
    expect(link.getAttribute('rel')).toContain('noopener');
  });

  it('escapes a title containing markup rather than rendering it', () => {
    handleJobsList({ jobs: [JOB({ title: '<img src=x onerror=alert(1)>' })], settings: {} });
    const title = cards()[0].querySelector('.job-card-title');
    expect(title.querySelector('img')).toBeNull();
    expect(title.textContent).toBe('<img src=x onerror=alert(1)>');
  });

  it('surfaces a dispatch failure on the card', () => {
    handleJobsList({ jobs: [JOB({ lastError: 'Failed to create worktree: disk full' })], settings: {} });
    expect(cards()[0].querySelector('.job-card-error').textContent).toMatch(/disk full/);
  });
});

describe('a redraw keeps the reader\'s place', () => {
  it('updates the To do column in place: same scroller, unchanged cards kept, scroll kept', () => {
    const many = Array.from({ length: 30 }, (_, i) => JOB({ id: `job-${i}`, title: `Card ${i}`, postedAt: new Date(Date.now() - (30 - i) * 1000).toISOString() }));
    handleJobsList({ jobs: many, settings: { running: true } });
    const scroller = document.querySelector('.job-column[data-state="todo"] .job-cards');
    const container = document.getElementById('job-columns');
    const first = columnCards('todo')[0];
    scroller.scrollTop = 400;
    container.scrollTop = 250;   // the phone, where the stacked columns scroll
    // A broadcast with one card edited, then the 30s clock tick.
    handleJobsList({ jobs: many.map(j => j.id === 'job-5' ? { ...j, title: 'Edited' } : j), settings: { running: true } });
    renderBoard();
    expect(document.querySelector('.job-column[data-state="todo"] .job-cards')).toBe(scroller);
    expect(scroller.scrollTop).toBe(400);
    expect(container.scrollTop).toBe(250);
    expect(columnCards('todo')[0]).toBe(first);
    expect(columnCards('todo')[5].querySelector('.job-card-title').textContent).toContain('Edited');
    expect(columnCards('todo')).toHaveLength(30);
  });
});

describe('live status badge', () => {
  const inProgress = (agentState, lastOutputAt = Date.now()) => {
    agents.set('s1', { name: 'Viper', state: agentState, lastOutputAt, termEl: document.createElement('div') });
    handleJobsList({ jobs: [JOB({ state: 'in-progress', agentSessionId: 's1', agentName: 'Viper' })], settings: {} });
    return cards()[0];
  };

  it('marks a working agent as running', () => {
    expect(inProgress('WORKING').querySelector('.job-card-status').textContent).toContain('running');
  });

  it('marks an agent at a permission prompt as needing the user', () => {
    const card = inProgress('MESSAGE');
    expect(card.querySelector('.job-card-status').textContent).toContain('needs you');
    expect(card.classList.contains('status-needs-input')).toBe(true);
  });

  it('only flags a WAITING agent once it has been quiet past the window', () => {
    expect(inProgress('WAITING', Date.now()).querySelector('.job-card-status').textContent).toContain('running');
    const stale = inProgress('WAITING', Date.now() - 10 * 60 * 1000);
    expect(stale.querySelector('.job-card-status').textContent).toContain('may need you');
  });

  it('marks a job whose agent has gone', () => {
    handleJobsList({ jobs: [JOB({ state: 'in-progress', agentSessionId: 'missing', agentName: 'Ghost' })], settings: {} });
    const badge = cards()[0].querySelector('.job-card-status');
    expect(badge.textContent).toContain('agent gone');
    expect(badge.disabled).toBe(true);   // nothing to jump to
  });

  it('jumps to the agent terminal when the badge is clicked', () => {
    const card = inProgress('MESSAGE');
    card.querySelector('.job-card-status').click();
    expect(switchToSession).toHaveBeenCalledWith('s1');
  });

  it('opens the agent tab when the card itself is clicked', () => {
    const card = inProgress('WORKING');
    expect(card.classList.contains('job-card-live')).toBe(true);
    card.click();
    expect(switchToSession).toHaveBeenCalledWith('s1');
  });

  it('leaves the keyboard path to the badge rather than nesting a role=button', () => {
    // The card holds buttons and a link; role="button" would hide them from
    // assistive tech, and the badge inside is already a real focusable button.
    const card = inProgress('WORKING');
    expect(card.getAttribute('role')).toBeNull();
    expect(card.hasAttribute('tabindex')).toBe(false);
    expect(card.querySelector('.job-card-status').disabled).toBe(false);
  });

  it('does not jump when the PR link on a live card is clicked', () => {
    // The action buttons stopPropagation themselves; the PR anchor does not, so
    // the card handler is the only thing keeping a PR click from also jumping.
    agents.set('s1', { name: 'Viper', state: 'WORKING', lastOutputAt: Date.now(), termEl: document.createElement('div') });
    handleJobsList({
      jobs: [JOB({ state: 'in-progress', agentSessionId: 's1', agentName: 'Viper', prUrl: 'https://gh/o/r/pull/4', prNumber: 4 })],
      settings: {},
    });
    const link = cards()[0].querySelector('.job-card-pr');
    link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
    expect(switchToSession).not.toHaveBeenCalled();
  });

  it('still jumps when the stale selection is somewhere else on the page', () => {
    // Guarding on any non-empty selection would make every card unclickable
    // after the user selected text in a terminal.
    const card = inProgress('WORKING');
    const elsewhere = document.createElement('p');
    elsewhere.textContent = 'selected in another pane';
    document.body.appendChild(elsewhere);
    const range = document.createRange();
    range.selectNodeContents(elsewhere);
    const sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
    card.click();
    expect(switchToSession).toHaveBeenCalledWith('s1');
    sel.removeAllRanges();
  });

  it('does not jump when the click only ended a text selection on the card', () => {
    const card = inProgress('WORKING');
    const range = document.createRange();
    range.selectNodeContents(card.querySelector('.job-card-detail'));
    const sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
    card.click();
    expect(switchToSession).not.toHaveBeenCalled();
    sel.removeAllRanges();
  });

  it('leaves the card inert when its agent is gone', () => {
    handleJobsList({ jobs: [JOB({ state: 'in-progress', agentSessionId: 'missing', agentName: 'Ghost' })], settings: {} });
    const card = cards()[0];
    expect(card.classList.contains('job-card-live')).toBe(false);
    card.click();
    expect(switchToSession).not.toHaveBeenCalled();
  });

  it('does not jump when a card action button is clicked', () => {
    const card = inProgress('WORKING');
    window.confirm = vi.fn(() => false);
    [...card.querySelectorAll('.job-card-btn')].find(b => b.textContent === 'Delete').click();
    expect(switchToSession).not.toHaveBeenCalled();
  });

  it('does not make todo cards clickable', () => {
    agents.set('s1', { name: 'Viper', state: 'WORKING', lastOutputAt: Date.now(), termEl: document.createElement('div') });
    handleJobsList({
      jobs: [JOB({ id: 'j1', state: 'todo', agentSessionId: 's1', agentName: 'Viper' })],
      settings: {},
    });
    expect(document.querySelectorAll('.job-card-live')).toHaveLength(0);
  });

  // Review keeps its agent until the card is done, so it is a jump target too.
  it('makes a review card with a kept agent clickable', () => {
    agents.set('s1', { name: 'Viper', state: 'WAITING', lastOutputAt: Date.now(), termEl: document.createElement('div') });
    handleJobsList({
      jobs: [JOB({ id: 'j2', state: 'review', agentSessionId: 's1', agentName: 'Viper' })],
      settings: {},
    });
    expect(document.querySelectorAll('.job-card-live')).toHaveLength(1);
  });

  it('shows no badge on todo or review cards', () => {
    handleJobsList({ jobs: [JOB({ state: 'todo' }), JOB({ id: 'j2', state: 'review' })], settings: {} });
    expect(document.querySelectorAll('.job-card-status')).toHaveLength(0);
  });
});

describe('a repo with no GitHub remote', () => {
  it('says on the card that the job cannot open its pull request', () => {
    handleJobsList({ jobs: [JOB({ noGithubRemote: true })], settings: {} });
    expect(document.querySelector('.job-card .job-card-error').textContent).toMatch(/no GitHub remote, so the job can't open a pull request/);
    handleJobsList({ jobs: [JOB()], settings: {} });
    expect(document.querySelector('.job-card .job-card-error')).toBeNull();
  });

  it('says so too when its CLI has no ship skill, naming the CLI and gstack', () => {
    handleJobsList({ jobs: [JOB({ noShipSkill: 'codex' })], settings: {} });
    expect(document.querySelector('.job-card .job-card-error').textContent).toMatch(/^Codex has no ship skill, so the job can't open a pull request; install gstack/);
  });

  it('drops both once the card has its pull request', () => {
    handleJobsList({ jobs: [JOB({ noGithubRemote: true, noShipSkill: 'claude', prUrl: 'https://github.com/o/r/pull/4', prNumber: 4 })], settings: {} });
    expect(document.querySelector('.job-card .job-card-error')).toBeNull();
  });
});

describe('the board-wide worker limit and memory gate', () => {
  const gate = (over = {}) => ({ maxWorkers: 5, cap: 5, running: 2, room: 3, level: 'ok', reasons: [], reason: null, memoryHeld: false, ...over });
  const todoNote = () => document.querySelector('.job-column[data-state="todo"] .job-board-gate');

  it('shows running against the limit, and the limit lowered for memory', () => {
    handleJobsList({ jobs: [], settings: { running: true, maxPerRepo: 2, intervalMs: 300000, gate: gate() } });
    expect(document.getElementById('job-dispatcher-status').textContent).toBe('scanning every 5m · 2/5 workers');
    expect(document.getElementById('job-max-workers').value).toBe('5');
    handleJobsList({ jobs: [], settings: { running: true, maxPerRepo: 2, intervalMs: 300000, gate: gate({ cap: 3, room: 1, level: 'pressure', reasons: ['swap 80% full'] }) } });
    const status = document.getElementById('job-dispatcher-status');
    expect(status.textContent).toBe('scanning every 5m · 2/3 workers (memory)');
    expect(status.classList.contains('memory')).toBe(true);
    expect(status.title).toBe('Memory: swap 80% full');
  });

  it('says why To do waits, and marks a card delayed by memory without dropping it', () => {
    const reason = 'Waiting: Mac memory is low (6.0 GB disk free). No new workers start until it recovers; running ones continue.';
    const held = gate({ cap: 0, room: 0, level: 'critical', reasons: ['6.0 GB disk free'], reason, memoryHeld: true });
    handleJobsList({ jobs: [JOB({ scheduleId: 'sched-1', delayedByMemoryAt: new Date().toISOString() })], settings: { running: true, maxPerRepo: 2, gate: held } });
    expect(todoNote().textContent).toBe(reason);
    expect(todoNote().classList.contains('memory')).toBe(true);
    expect(cards()[0].querySelector('.job-card-next').textContent).toMatch(/^delayed by memory · since /);
  });

  it('a full board with memory fine: the limit, not memory', () => {
    const full = gate({ running: 5, room: 0, reason: "Waiting: 5 of 5 workers running, the board's limit across all repos." });
    handleJobsList({ jobs: [JOB()], settings: { running: true, maxPerRepo: 2, gate: full } });
    expect(todoNote().textContent).toMatch(/5 of 5 workers running/);
    expect(todoNote().classList.contains('memory')).toBe(false);
    expect(cards()[0].querySelector('.job-card-next').textContent).toBe('runs when a slot frees');
    handleJobsList({ jobs: [JOB()], settings: { running: true, maxPerRepo: 2, gate: gate() } });
    expect(todoNote()).toBeNull();
  });

  it('lists To do in dispatch order: urgent Billion cards and schedule runs first', () => {
    const at = (m) => new Date(Date.UTC(2026, 9, 28, 8, m)).toISOString();
    handleJobsList({ jobs: [
      JOB({ id: 'old', title: 'old', postedAt: at(0) }),
      JOB({ id: 'run', title: 'run', postedAt: at(5), scheduleId: 's' }),
      JOB({ id: 'urgent', title: 'urgent', postedAt: at(9), urgent: true, postedByBillion: true }),
    ], settings: { running: true, maxPerRepo: 2, gate: gate() } });
    expect(cards().map(c => c.dataset.jobId)).toEqual(['run', 'urgent', 'old']);
  });

  it('sends a new board-wide limit', () => {
    const total = document.getElementById('job-max-workers');
    total.value = '4';
    total.dispatchEvent(new Event('change'));
    expect(send).toHaveBeenCalledWith({ type: 'job-settings', maxWorkers: 4 });
  });
});

describe('dispatcher controls', () => {
  it('reflects the stopped state and starts on click', () => {
    handleJobsList({ jobs: [], settings: { running: false, maxPerRepo: 2, intervalMs: 180000 } });
    expect(document.getElementById('btn-dispatcher-toggle').textContent).toBe('Start');
    expect(document.getElementById('job-dispatcher-status').textContent).toMatch(/stopped/);

    document.getElementById('btn-dispatcher-toggle').click();
    expect(send).toHaveBeenCalledWith({ type: 'job-settings', running: true });
  });

  it('reflects the running state and its interval', () => {
    handleJobsList({ jobs: [], settings: { running: true, maxPerRepo: 2, intervalMs: 300000 } });
    expect(document.getElementById('btn-dispatcher-toggle').textContent).toBe('Stop');
    expect(document.getElementById('job-dispatcher-status').textContent).toMatch(/every 5m/);
  });

  it('says in To do why a card waits while the board is stopped, with Start beside it', () => {
    handleJobsList({ jobs: [JOB()], settings: { running: false } });
    const line = document.querySelector('.job-column[data-state="todo"] .job-board-stopped');
    expect(line.textContent).toMatch(/The board is stopped: cards wait here until you press Start/);
    line.querySelector('button').click();
    expect(send).toHaveBeenCalledWith({ type: 'job-settings', running: true });
  });

  it('says nothing about a stopped board with no card waiting, or a running one', () => {
    handleJobsList({ jobs: [], settings: { running: false } });
    expect(document.querySelector('.job-board-stopped')).toBeNull();
    handleJobsList({ jobs: [JOB()], settings: { running: true } });
    expect(document.querySelector('.job-board-stopped')).toBeNull();
  });

  it('sends a manual scan', () => {
    document.getElementById('btn-dispatch-now').click();
    expect(send).toHaveBeenCalledWith({ type: 'job-dispatch-now' });
  });

  it('sends a new cap', () => {
    const cap = document.getElementById('job-max-per-repo');
    cap.value = '4';
    cap.dispatchEvent(new Event('change'));
    expect(send).toHaveBeenCalledWith({ type: 'job-settings', maxPerRepo: 4 });
  });

  it('shows the board permission mode and sends a new one', () => {
    handleJobsList({ jobs: [], settings: { running: false, maxPerRepo: 2, permissionMode: 'acceptEdits' } });
    const perm = document.getElementById('job-permission-mode');
    expect(perm.value).toBe('acceptEdits');
    perm.value = 'plan';
    perm.dispatchEvent(new Event('change'));
    // Sending it is also what records the choice server-side, which is what
    // stops the migration in boardSettings() resetting it again.
    expect(send).toHaveBeenCalledWith({ type: 'job-settings', permissionMode: 'plan' });
  });
});

describe('the per-job permission mode', () => {
  const perm = () => document.getElementById('job-permission-mode-field');

  it('opens on "board default" for a card with no mode of its own', () => {
    // Never pre-filled with the board's current value: that would freeze the
    // card onto today's setting the next time anyone opened the form.
    handleJobsList({ jobs: [JOB()], settings: { running: false, maxPerRepo: 2, permissionMode: 'plan' } });
    cards()[0].querySelector('.job-card-actions button').click();
    expect(perm().value).toBe('');
  });

  it('colours "board default" when what the board decides is bypass', () => {
    handleJobsList({ jobs: [JOB()], settings: { running: false, maxPerRepo: 2, permissionMode: 'auto', permissionModeChosen: false,
      envModes: { claude: null, codex: 'bypassPermissions' } } });
    cards()[0].querySelector('.job-card-actions button').click();
    expect(perm().value).toBe('');
    expect(perm().classList).toContain('job-mode-danger');
    handleJobsList({ jobs: [JOB()], settings: { running: false, maxPerRepo: 2, permissionMode: 'auto', permissionModeChosen: true,
      envModes: { claude: null, codex: 'bypassPermissions' } } });
    cards()[0].querySelector('.job-card-actions button').click();
    expect(perm().classList).not.toContain('job-mode-danger');   // a picked mode overrides the .env one
    handleJobsList({ jobs: [], settings: { permissionModeChosen: false, envModes: { claude: null, codex: null } } });
  });

  it('shows and sends the mode a card carries', () => {
    handleJobsList({ jobs: [JOB({ permissionMode: 'plan' })], settings: { running: false, maxPerRepo: 2 } });
    cards()[0].querySelector('.job-card-actions button').click();
    expect(perm().value).toBe('plan');
    perm().value = 'acceptEdits';
    document.getElementById('btn-job-save').click();
    expect(send).toHaveBeenCalledWith(expect.objectContaining({
      type: 'job-update', jobId: 'job-1', permissionMode: 'acceptEdits',
    }));
  });

  it('puts an overriding mode on the card face', () => {
    handleJobsList({ jobs: [JOB({ permissionMode: 'bypassPermissions' })], settings: { running: false, maxPerRepo: 2 } });
    expect(cards()[0].querySelector('.job-card-chips').textContent).toContain('bypassPermissions');
  });

  it('shows both the scheduled chip and the permission-mode chip together', () => {
    // Two sibling .job-card-type spans. A querySelector (singular) assertion
    // elsewhere would only ever see the first, so this pins both.
    handleJobsList({ jobs: [JOB({ type: 'scheduled', schedule: '@daily', permissionMode: 'plan' })], settings: { running: false, maxPerRepo: 2 } });
    const chips = [...cards()[0].querySelectorAll('.job-card-type')].map(c => c.textContent);
    expect(chips).toEqual(['recurring', 'plan']);
  });

  it('colours only the bypassPermissions chip as dangerous', () => {
    handleJobsList({ jobs: [JOB({ permissionMode: 'bypassPermissions' })], settings: { running: false, maxPerRepo: 2 } });
    expect(cards()[0].querySelector('.job-card-type').classList).toContain('job-mode-danger');
    handleJobsList({ jobs: [JOB({ permissionMode: 'plan' })], settings: { running: false, maxPerRepo: 2 } });
    expect(cards()[0].querySelector('.job-card-type').classList).not.toContain('job-mode-danger');
  });

  it('shows the .env defaults while no mode is picked, and lets any pick override them', () => {
    handleJobsList({ jobs: [], settings: { running: false, maxPerRepo: 2, permissionMode: 'auto', permissionModeChosen: false,
      envModes: { claude: 'bypassPermissions', codex: null } } });
    const perm = document.getElementById('job-permission-mode');
    expect(perm.value).toBe('');
    expect(perm.selectedOptions[0].textContent).toBe('.env: claude bypassPermissions, codex auto');
    expect(perm.classList).toContain('job-mode-danger');   // workers really run in bypass
    // Picking the built-in mode is a change too, so it is sent and recorded.
    perm.value = 'auto';
    perm.dispatchEvent(new Event('change'));
    expect(send).toHaveBeenCalledWith({ type: 'job-settings', permissionMode: 'auto' });
    // Once a mode is picked, the .env entry goes.
    handleJobsList({ jobs: [], settings: { running: false, maxPerRepo: 2, permissionMode: 'auto', permissionModeChosen: true,
      envModes: { claude: 'bypassPermissions', codex: null } } });
    expect(perm.value).toBe('auto');
    expect(perm.querySelector('option[value=""]')).toBeNull();
    expect(perm.classList).not.toContain('job-mode-danger');
    handleJobsList({ jobs: [], settings: { permissionModeChosen: false, envModes: { claude: null, codex: null } } });
  });

  it('marks the toolbar select when the board itself is on bypassPermissions', () => {
    handleJobsList({ jobs: [], settings: { running: false, maxPerRepo: 2, permissionMode: 'bypassPermissions' } });
    expect(document.getElementById('job-permission-mode').classList).toContain('job-mode-danger');
    handleJobsList({ jobs: [], settings: { running: false, maxPerRepo: 2, permissionMode: 'auto' } });
    expect(document.getElementById('job-permission-mode').classList).not.toContain('job-mode-danger');
  });

  it('leaves an inheriting card unchipped', () => {
    handleJobsList({ jobs: [JOB()], settings: { running: false, maxPerRepo: 2 } });
    expect(cards()[0].querySelector('.job-card-type')).toBeNull();
  });
});

describe('job form', () => {
  it('offers the discovered models for the chosen agent, and sends the pick', () => {
    handleJobsList({ jobs: [], settings: {}, models: { claude: ['opus', 'haiku'], codex: ['gpt-6-luna'] } });
    document.getElementById('btn-new-job').click();
    expect(send).toHaveBeenCalledWith({ type: 'models-refresh' });
    const model = document.getElementById('job-model');
    expect([...model.options].map(o => o.value)).toEqual(['', 'opus', 'haiku']);
    document.getElementById('job-agent').value = 'codex';
    document.getElementById('job-agent').dispatchEvent(new Event('change'));
    expect([...model.options].map(o => o.value)).toEqual(['', 'gpt-6-luna']);
    model.value = 'gpt-6-luna';
    document.getElementById('job-title').value = 'Task';
    document.getElementById('btn-job-save').click();
    expect(send).toHaveBeenCalledWith(expect.objectContaining({ type: 'job-create', agent: 'codex', model: 'gpt-6-luna' }));
  });

  it('shows the model on the card and keeps it on an edit that does not touch it', () => {
    handleJobsList({ jobs: [JOB({ model: 'opus' })], settings: {}, models: { claude: [], codex: [] } });
    expect(cards()[0].querySelector('.job-card-runs-on').textContent).toBe('claude · opus');
    [...cards()[0].querySelectorAll('.job-card-btn')].find(b => b.textContent === 'Edit').click();
    // No longer discovered, but still the card's: offered, selected, not re-sent.
    expect(document.getElementById('job-model').value).toBe('opus');
    document.getElementById('btn-job-save').click();
    const update = send.mock.calls.map(c => c[0]).find(m => m.type === 'job-update');
    expect(update).not.toHaveProperty('model');
  });

  it('posts a new job with the chosen repo', () => {
    document.getElementById('btn-new-job').click();
    document.getElementById('job-title').value = 'New task';
    document.getElementById('job-detail').value = 'Some detail';
    document.getElementById('btn-job-save').click();
    expect(send).toHaveBeenCalledWith({
      type: 'job-create', title: 'New task', detail: 'Some detail', repoPath: '/repos/alpha',
      jobType: 'one-time', schedule: '', permissionMode: '', agent: 'claude', requiresPr: true, skills: [], attachments: [], model: '', runAt: null,
    });
  });

  it('posts a codex job, hiding the Claude-only modes except one already picked', () => {
    document.getElementById('btn-new-job').click();
    document.getElementById('job-title').value = 'Codex task';
    const perm = document.getElementById('job-permission-mode-field');
    perm.value = 'plan';
    const agent = document.getElementById('job-agent');
    agent.value = 'codex';
    agent.dispatchEvent(new Event('change'));
    // The pick survives: the server maps plan onto Codex's read-only sandbox.
    expect(perm.value).toBe('plan');
    expect(perm.querySelector('option[value="plan"]').hidden).toBe(false);
    expect(perm.querySelector('option[value="manual"]').hidden).toBe(true);
    expect(perm.querySelector('option[value="manual"]').disabled).toBe(true);
    expect(perm.querySelector('option[value="bypassPermissions"]').hidden).toBe(false);
    perm.value = 'bypassPermissions';
    document.getElementById('btn-job-save').click();
    expect(send).toHaveBeenCalledWith(expect.objectContaining({ type: 'job-create', agent: 'codex', permissionMode: 'bypassPermissions' }));
    // Reopening for a claude card shows every mode again.
    document.getElementById('btn-new-job').click();
    expect(perm.querySelector('option[value="plan"]').hidden).toBe(false);
  });

  it('shows codex on the card and pre-fills the agent when editing', () => {
    handleJobsList({ jobs: [JOB({ agent: 'codex' })], settings: { running: false, maxPerRepo: 2 } });
    expect(cards()[0].querySelector('.job-card-runs-on').textContent).toBe('codex · default');
    cards()[0].querySelector('.job-card-actions button').click();
    expect(document.getElementById('job-agent').value).toBe('codex');
  });

  it('opens a codex card with the Claude-only modes hidden, and sends the agent on save', () => {
    handleJobsList({ jobs: [JOB({ agent: 'codex', permissionMode: 'bypassPermissions' })], settings: { running: false, maxPerRepo: 2 } });
    cards()[0].querySelector('.job-card-actions button').click();
    const perm = document.getElementById('job-permission-mode-field');
    expect(perm.querySelector('option[value="manual"]').hidden).toBe(true);
    expect(perm.value).toBe('bypassPermissions');
    expect(perm.classList).toContain('job-mode-danger');
    document.getElementById('btn-job-save').click();
    expect(send).toHaveBeenCalledWith(expect.objectContaining({ type: 'job-update', jobId: 'job-1', agent: 'codex', permissionMode: 'bypassPermissions' }));
  });

  // A card can hold a Claude-only mode on Codex (a ws client, config.json, or
  // a Claude card switched to Codex in the form); the server maps it onto a
  // stricter Codex flag, so opening the card must not quietly drop it.
  it('keeps a stored Claude-only mode on a codex card and sends it back unchanged', () => {
    handleJobsList({ jobs: [JOB({ agent: 'codex', permissionMode: 'plan' })], settings: { running: false, maxPerRepo: 2 } });
    cards()[0].querySelector('.job-card-actions button').click();
    const perm = document.getElementById('job-permission-mode-field');
    expect(perm.value).toBe('plan');
    expect(perm.querySelector('option[value="plan"]').hidden).toBe(false);
    expect(perm.querySelector('option[value="manual"]').hidden).toBe(true);
    document.getElementById('job-title').value = 'typo fixed';
    document.getElementById('btn-job-save').click();
    expect(send).toHaveBeenCalledWith(expect.objectContaining({ type: 'job-update', jobId: 'job-1', agent: 'codex', permissionMode: 'plan' }));
  });

  it('switching a card back to claude unhides every mode and sends claude', () => {
    handleJobsList({ jobs: [JOB({ agent: 'codex' })], settings: { running: false, maxPerRepo: 2 } });
    cards()[0].querySelector('.job-card-actions button').click();
    const perm = document.getElementById('job-permission-mode-field');
    const agent = document.getElementById('job-agent');
    agent.value = 'claude';
    agent.dispatchEvent(new Event('change'));
    expect(perm.querySelector('option[value="plan"]').hidden).toBe(false);
    perm.value = 'plan';
    document.getElementById('btn-job-save').click();
    expect(send).toHaveBeenCalledWith(expect.objectContaining({ type: 'job-update', jobId: 'job-1', agent: 'claude', permissionMode: 'plan' }));
  });

  it('opens a claude card on claude even right after a codex one', () => {
    handleJobsList({ jobs: [JOB({ id: 'c', agent: 'codex' }), JOB({ id: 'k' })], settings: { running: false, maxPerRepo: 2 } });
    cards()[0].querySelector('.job-card-actions button').click();
    expect(document.getElementById('job-agent').value).toBe('codex');
    closeJobForm();
    cards()[1].querySelector('.job-card-actions button').click();
    expect(document.getElementById('job-agent').value).toBe('claude');
    expect(document.getElementById('job-permission-mode-field').querySelector('option[value="plan"]').hidden).toBe(false);
  });

  it('puts the type on the title line, and leads the lower chips with the agent·model pill', () => {
    handleJobsList({ jobs: [JOB({ type: 'scheduled', schedule: '@daily', agent: 'codex', model: 'gpt-5.5', permissionMode: 'plan' })], settings: { running: false, maxPerRepo: 2 } });
    const chips = [...cards()[0].querySelector('.job-card-chips').children].map(c => c.textContent);
    expect(chips).toEqual(['codex · gpt-5.5', 'plan']);
    // The type is on the title line, not in the lower row.
    expect(cards()[0].querySelector('.job-card-title .job-card-type').textContent).toBe('recurring');
    handleJobsList({ jobs: [JOB({ agent: 'claude' })], settings: { running: false, maxPerRepo: 2 } });
    expect([...cards()[0].querySelector('.job-card-chips').children].map(c => c.textContent)).toEqual(['claude · default']);
    expect(cards()[0].querySelectorAll('.job-card-type')).toHaveLength(0);
  });

  it('shows every card its agent, dropping only the redundant "claude-" from the model', () => {
    handleJobsList({ jobs: [JOB({ model: 'claude-opus-5-5' }), JOB({ id: 'job-2', agent: 'codex' })], settings: { running: false, maxPerRepo: 2 } });
    const pill = document.querySelector('[data-job-id="job-1"] .job-card-runs-on');
    expect(pill.textContent).toBe('claude · opus-5-5');
    expect(pill.title).toContain('claude-opus-5-5');
    const plain = document.querySelector('[data-job-id="job-2"] .job-card-runs-on');
    expect(plain.textContent).toBe('codex · default');
    expect(plain.title).toBe("Runs the CLI's default model");
  });

  // A screenshot pasted into Details becomes a named base64 attachment on the
  // create message, and the terminal's own paste handler (mocked here) is not
  // what receives it.
  it('sends a pasted screenshot as an attachment, once it has been read', async () => {
    document.getElementById('btn-new-job').click();
    document.getElementById('job-title').value = 'Fix header';
    const file = new File([new Uint8Array([1, 2, 3])], 'image.png', { type: 'image/png' });
    const e = new Event('paste', { bubbles: true, cancelable: true });
    e.clipboardData = { files: [file] };
    document.getElementById('job-detail').dispatchEvent(e);
    expect(e.defaultPrevented).toBe(true);
    expect(document.querySelectorAll('.job-attachment')).toHaveLength(1);
    // FileReader is async: a save before it finishes is refused, not sent half-read.
    document.getElementById('btn-job-save').click();
    expect(send).not.toHaveBeenCalledWith(JOB_SAVE);
    expect(document.getElementById('job-form-error').textContent).toMatch(/still reading/i);
    await new Promise(r => setTimeout(r, 50));
    document.getElementById('btn-job-save').click();
    expect(send).toHaveBeenCalledWith(expect.objectContaining({
      type: 'job-create',
      attachments: [{ name: expect.stringMatching(/^screenshot-\d+\.png$/), data: 'AQID' }],
    }));
  });

  it('an edit that removes the last chip sends an empty list so the server deletes the file', () => {
    handleJobsList({ jobs: [JOB({ attachments: [{ name: 'shot.png', path: '/cfg/x/shot.png' }] })], settings: {} });
    const edit = [...cards()[0].querySelectorAll('.job-card-btn')].find(b => b.textContent === 'Edit');
    edit.click();
    expect(document.querySelectorAll('.job-attachment')).toHaveLength(1);
    document.querySelector('.job-attachment-remove').click();
    document.getElementById('btn-job-save').click();
    expect(send).toHaveBeenCalledWith(expect.objectContaining({ type: 'job-update', jobId: 'job-1', attachments: [] }));
  });

  it('links each attachment on the card to the download route', () => {
    handleJobsList({ jobs: [JOB({ attachments: [{ name: 'a b.png', path: '/cfg/x/a b.png' }] })], settings: {} });
    const link = cards()[0].querySelector('.job-card-attachment');
    expect(link.getAttribute('href')).toBe('/api/jobs/job-1/attachments/a%20b.png');
    expect(link.rel).toContain('noopener');
  });

  it('refuses an empty title instead of posting', () => {
    document.getElementById('btn-new-job').click();
    document.getElementById('job-title').value = '   ';
    document.getElementById('btn-job-save').click();
    expect(send).not.toHaveBeenCalledWith(JOB_SAVE);
    expect(document.getElementById('job-form-error').style.display).toBe('block');
  });

  it('explains itself when no repo has been added yet', () => {
    repos.clear();
    document.getElementById('btn-new-job').click();
    document.getElementById('job-title').value = 'Task';
    document.getElementById('btn-job-save').click();
    expect(send).not.toHaveBeenCalledWith(JOB_SAVE);
    expect(document.getElementById('job-form-error').textContent).toMatch(/repository/i);
  });
});

describe('cards that need no pull request', () => {
  it('chips a no-PR card, shows its result in full, and leaves a plain card unchipped', () => {
    handleJobsList({
      jobs: [
        JOB({ id: 'a', state: 'review', requiresPr: false, resultSummary: 'An unclosed onData listener in pty.js.' }),
        JOB({ id: 'b', state: 'todo', requiresPr: true }),
        JOB({ id: 'c', state: 'todo', type: 'scheduled', schedule: '@daily', requiresPr: false }),
      ],
      settings: {},
    });
    const [review] = columnCards('review');
    expect([...review.querySelectorAll('.job-card-type')].map(c => c.textContent)).toEqual(['no PR']);
    expect(review.querySelector('.job-card-result').textContent).toBe('An unclosed onData listener in pty.js.');
    const [plain, scheduled] = columnCards('todo');
    expect(plain.querySelector('.job-card-result')).toBeNull();
    expect([...plain.querySelectorAll('.job-card-type')].map(c => c.textContent)).not.toContain('no PR');
    expect([...scheduled.querySelectorAll('.job-card-type')].map(c => c.textContent)).not.toContain('no PR');
  });

  it('opens a no-PR card on "Not required", sends it back, and defaults a schedule to Not required', () => {
    handleJobsList({ jobs: [JOB({ requiresPr: false })], settings: {} });
    document.querySelector('.job-card-btn').click();   // Edit
    const pr = document.getElementById('job-requires-pr');
    expect(pr.value).toBe('no');
    document.getElementById('btn-job-save').click();
    expect(send).toHaveBeenCalledWith(expect.objectContaining({ type: 'job-update', jobId: 'job-1', requiresPr: false }));
    document.getElementById('btn-new-job').click();
    expect(pr.value).toBe('yes');
    const type = document.getElementById('job-type');
    type.value = 'scheduled';
    type.dispatchEvent(new Event('change'));
    expect(pr.value).toBe('no');   // a schedule's runs report a summary unless asked
    type.value = 'one-time';
    type.dispatchEvent(new Event('change'));
    expect(pr.value).toBe('yes');
    // A pick by hand sticks through a type change.
    pr.value = 'no';
    pr.dispatchEvent(new Event('change'));
    type.value = 'scheduled';
    type.dispatchEvent(new Event('change'));
    type.value = 'one-time';
    type.dispatchEvent(new Event('change'));
    expect(pr.value).toBe('no');
  });

  // Same rule as the server: a type change without a pick takes the new
  // type's default, whether the card is new or being edited.
  it('flips an existing card\'s PR choice with its type, unless picked by hand', () => {
    handleJobsList({ jobs: [JOB()], settings: {} });
    document.querySelector('.job-card-btn').click();   // Edit
    const type = document.getElementById('job-type');
    const pr = document.getElementById('job-requires-pr');
    type.value = 'scheduled';
    type.dispatchEvent(new Event('change'));
    expect(pr.value).toBe('no');
    pr.value = 'yes';
    pr.dispatchEvent(new Event('change'));
    type.value = 'one-time';
    type.dispatchEvent(new Event('change'));
    type.value = 'scheduled';
    type.dispatchEvent(new Event('change'));
    expect(pr.value).toBe('yes');
  });

  it('opens a schedule that never chose on Not required', () => {
    handleJobsList({ jobs: [JOB({ type: 'scheduled', schedule: '@daily' })], settings: {} });
    document.querySelector('.job-card-btn').click();   // Edit
    expect(document.getElementById('job-requires-pr').value).toBe('no');
  });

  it('opens a schedule that asked for PR runs on Required', () => {
    handleJobsList({ jobs: [JOB({ type: 'scheduled', schedule: '@daily', requiresPr: true })], settings: {} });
    document.querySelector('.job-card-btn').click();   // Edit
    expect(document.getElementById('job-requires-pr').value).toBe('yes');
  });
});

describe('card actions', () => {
  it('offers a manual move to review while in progress', () => {
    handleJobsList({ jobs: [JOB({ state: 'in-progress', agentSessionId: 's1', agentName: 'V' })], settings: {} });
    const btn = [...cards()[0].querySelectorAll('.job-card-btn')].find(b => b.textContent.includes('Review'));
    btn.click();
    expect(send).toHaveBeenCalledWith({ type: 'job-move', jobId: 'job-1', state: 'review' });
  });

  it('confirms before deleting', () => {
    handleJobsList({ jobs: [JOB()], settings: {} });
    const del = [...cards()[0].querySelectorAll('.job-card-btn')].find(b => b.textContent === 'Delete');

    window.confirm = vi.fn(() => false);
    del.click();
    expect(send).not.toHaveBeenCalled();

    window.confirm = vi.fn(() => true);
    del.click();
    expect(send).toHaveBeenCalledWith({ type: 'job-delete', jobId: 'job-1' });
  });
});

describe('archiving a To do card', () => {
  const button = (label) => [...document.querySelectorAll('.job-card-btn')].find(b => b.textContent === label);

  it('offers Archive on a To do card, asks first, then sends job-archive', () => {
    handleJobsList({ jobs: [JOB({ type: 'scheduled', schedule: '0 10 24 9 *' })], settings: {} });
    window.confirm = vi.fn(() => false);
    button('Archive').click();
    expect(send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'job-archive' }));
    window.confirm = vi.fn(() => true);
    button('Archive').click();
    expect(send).toHaveBeenCalledWith(expect.objectContaining({ type: 'job-archive', jobId: 'job-1' }));
  });

  it('shows an archived schedule in Finished with its note and no Pause or Archive', () => {
    handleJobsList({ jobs: [JOB({ type: 'scheduled', schedule: '0 10 24 9 *', once: true, state: 'done', archivedAt: new Date().toISOString(), archivedBy: 'Billion', archivedReason: 'Ran once' })], settings: {} });
    document.getElementById('btn-finished-jobs').click();
    const card = document.querySelector('#job-finished-cards .job-card');
    expect(card.querySelector('.job-card-finished').textContent).toMatch(/archived .* by Billion: Ran once/);
    expect(card.querySelector('.job-card-type').textContent).toBe('scheduled');
    expect(button('Pause')).toBeUndefined();
    expect(button('Archive')).toBeUndefined();
  });
});

describe('show / hide', () => {
  it('hides the board and leaves the active terminal alone', () => {
    const termEl = document.createElement('div');
    agents.set('s9', { name: 'A', state: 'WORKING', termEl, lastOutputAt: Date.now() });
    setActiveSession('s9');
    showJobBoard();
    expect(termEl.style.display).toBe('none');
    expect(document.getElementById('job-board').style.display).toBe('flex');

    hideJobBoard();
    expect(document.getElementById('job-board').style.display).toBe('none');
  });

  it('brings the board up before opening the form from the top bar', () => {
    hideJobBoard();
    // app.js wires this hook to updateTabs(). The pinned board tab has to
    // repaint as active, or the form appears under a tab strip still
    // highlighting the agent the user was looking at.
    const repaintTabs = vi.fn();
    window._onBoardVisibilityChanged = repaintTabs;
    try {
      document.getElementById('btn-new-job-shortcut').click();
      expect(document.getElementById('job-board').style.display).toBe('flex');
      expect(document.getElementById('job-form').style.display).toBe('flex');
      expect(repaintTabs).toHaveBeenCalled();
    } finally {
      delete window._onBoardVisibilityChanged;
    }
  });

  it('dismisses the agent spawn overlay before focusing the job title', () => {
    // The spawn form covers everything below the 36px header strip but leaves
    // the header buttons clickable. Opening the job form underneath it would
    // focus a title input the user cannot see, and eat what they typed next.
    const spawn = document.getElementById('spawn-form');
    spawn.style.display = 'flex';
    openJobForm();
    expect(spawn.style.display).toBe('none');
    expect(document.activeElement.id).toBe('job-title');
  });

  it('dismisses the spawn overlay from the board\'s own buttons too', () => {
    // The overlay sizes to its content instead of covering the viewport, so
    // the board's toolbar and cards stay clickable underneath it. Every door
    // into the form has to clear it, not just the top-bar shortcut.
    handleJobsList({ jobs: [JOB()], settings: {} });
    const spawn = document.getElementById('spawn-form');

    spawn.style.display = 'flex';
    document.getElementById('btn-new-job').click();
    expect(spawn.style.display).toBe('none');

    spawn.style.display = 'flex';
    const edit = [...document.querySelectorAll('.job-card button')]
      .find((b) => b.textContent === 'Edit');
    edit.click();
    expect(spawn.style.display).toBe('none');
  });

  it('opens the form even on a page with no spawn overlay', () => {
    document.getElementById('spawn-form').remove();
    expect(() => openJobForm()).not.toThrow();
    expect(document.getElementById('job-form').style.display).toBe('flex');
  });

  it('closeJobForm dismisses the form so the spawn overlay cannot hide it', () => {
    openJobForm();
    closeJobForm();
    expect(document.getElementById('job-form').style.display).toBe('none');
  });
});

describe('PR link safety', () => {
  it('renders an https PR link', () => {
    handleJobsList({ jobs: [JOB({ state: 'review', prUrl: 'https://gh/o/r/pull/3', prNumber: 3 })], settings: {} });
    expect(document.querySelector('.job-card-pr').getAttribute('href')).toBe('https://gh/o/r/pull/3');
  });

  it('refuses a non-http scheme rather than putting it in an href', () => {
    // job.prUrl comes from `gh pr list`, but a javascript: href executes on
    // click — cheap guard on a value that crosses a trust boundary.
    for (const bad of ['javascript:alert(1)', 'data:text/html,<script>1</script>', 'file:///etc/passwd']) {
      handleJobsList({ jobs: [JOB({ id: 'x', state: 'review', prUrl: bad, prNumber: 1 })], settings: {} });
      expect(document.querySelector('.job-card-pr')).toBeNull();
    }
  });
});


// --- Finished jobs ---

describe('finished jobs', () => {
  const finishedJob = (over = {}) => JOB({
    id: 'done-1', state: 'done', title: 'Shipped thing',
    branchName: 'bill/shipped', prUrl: 'https://gh/o/r/pull/12', prNumber: 12,
    reviewAt: '2026-08-28T09:00:00Z',
    prMergedAt: '2026-08-28T10:00:00Z', doneAt: '2026-08-28T10:00:01Z',
    ...over,
  });

  function finishedCards() {
    return [...document.querySelectorAll('#job-finished-cards .job-card')];
  }

  it('keeps a merged job off the board entirely', () => {
    // The point of the whole change: Review means "needs your review", so a
    // finished card must not be sitting in it.
    handleJobsList({ jobs: [finishedJob(), JOB({ id: 'r', state: 'review' })], settings: {} });
    expect(columnCards('review')).toHaveLength(1);
    expect(cards().map(c => c.dataset.jobId)).not.toContain('done-1');
  });

  it('counts the finished jobs on the toolbar button', () => {
    handleJobsList({ jobs: [finishedJob(), finishedJob({ id: 'done-2' })], settings: {} });
    expect(document.getElementById('btn-finished-jobs').textContent).toBe('View finished jobs (2)');
  });

  it('drops the count when there is nothing finished', () => {
    handleJobsList({ jobs: [JOB()], settings: {} });
    expect(document.getElementById('btn-finished-jobs').textContent).toBe('View finished jobs');
  });

  it('swaps the columns for the finished list, and back again', () => {
    handleJobsList({ jobs: [finishedJob(), JOB()], settings: {} });
    const columns = document.getElementById('job-columns');
    const panel = document.getElementById('job-finished');
    expect(panel.style.display).toBe('none');

    document.getElementById('btn-finished-jobs').click();
    expect(panel.style.display).toBe('flex');
    expect(columns.style.display).toBe('none');   // never both at once
    expect(finishedCards()).toHaveLength(1);
    expect(document.getElementById('btn-finished-jobs').textContent).toMatch(/Back to board/);

    document.getElementById('btn-finished-jobs').click();
    expect(panel.style.display).toBe('none');
    expect(columns.style.display).not.toBe('none');
    expect(columnCards('todo')).toHaveLength(1);
  });

  it('shows when the PR merged, and keeps the link to it', () => {
    handleJobsList({ jobs: [finishedJob()], settings: {} });
    document.getElementById('btn-finished-jobs').click();
    const card = finishedCards()[0];
    expect(card.querySelector('.job-card-finished').textContent).toMatch(/^merged /);
    expect(card.querySelector('.job-card-pr').getAttribute('href')).toBe('https://gh/o/r/pull/12');
  });

  it('says "finished" rather than "merged" for a job filed away by hand', () => {
    handleJobsList({ jobs: [finishedJob({ prMergedAt: null })], settings: {} });
    document.getElementById('btn-finished-jobs').click();
    expect(finishedCards()[0].querySelector('.job-card-finished').textContent).toMatch(/^finished /);
  });

  it('lists the most recently finished first', () => {
    handleJobsList({
      jobs: [
        finishedJob({ id: 'old', doneAt: '2026-08-01T10:00:00Z' }),
        finishedJob({ id: 'new', doneAt: '2026-08-27T10:00:00Z' }),
      ],
      settings: {},
    });
    document.getElementById('btn-finished-jobs').click();
    expect(finishedCards().map(c => c.dataset.jobId)).toEqual(['new', 'old']);
  });

  it('explains the empty archive rather than showing a blank pane', () => {
    handleJobsList({ jobs: [JOB()], settings: {} });
    document.getElementById('btn-finished-jobs').click();
    expect(document.querySelector('#job-finished-cards .job-column-empty').textContent).toMatch(/No finished jobs/);
  });

  it('offers no way back onto the board — only Delete', () => {
    // Done is terminal: the card is the record of what shipped, and the server
    // refuses every move out of it. Rendering a button the server would reject
    // is worse than rendering none.
    handleJobsList({ jobs: [finishedJob()], settings: {} });
    document.getElementById('btn-finished-jobs').click();
    const labels = [...finishedCards()[0].querySelectorAll('.job-card-btn')].map(b => b.textContent);
    expect(labels).toEqual(['Delete']);
  });

  it('offers Done on a review card, for a merge the board cannot see', () => {
    // It asks first, like Delete: the move cannot be undone.
    handleJobsList({ jobs: [JOB({ id: 'r', state: 'review' })], settings: {} });
    const doneBtn = [...cards()[0].querySelectorAll('.job-card-btn')]
      .find(b => b.textContent.includes('Done'));
    window.confirm = vi.fn(() => true);
    doneBtn.click();
    expect(send).toHaveBeenCalledWith({ type: 'job-move', jobId: 'r', state: 'done' });
  });

  it('warns that filing a card away closes its agent', () => {
    // Same disclosure Delete makes: this move retires the agent, and one
    // re-adopted on a shipped branch is a terminal someone is working in.
    handleJobsList({ jobs: [JOB({ id: 'r', state: 'review', agentSessionId: 'session-1', agentName: 'Viper' })], settings: {} });
    const doneBtn = [...cards()[0].querySelectorAll('.job-card-btn')]
      .find(b => b.textContent.includes('Done'));
    window.confirm = vi.fn(() => false);
    doneBtn.click();
    expect(window.confirm.mock.calls[0][0]).toMatch(/Viper is closed/);
  });

  it('does not file a card away when the confirm is declined', () => {
    handleJobsList({ jobs: [JOB({ id: 'r', state: 'review' })], settings: {} });
    const doneBtn = [...cards()[0].querySelectorAll('.job-card-btn')]
      .find(b => b.textContent.includes('Done'));
    window.confirm = vi.fn(() => false);
    doneBtn.click();
    expect(send).not.toHaveBeenCalled();
  });

  it('stays in the archive when the server broadcasts a new job list', () => {
    // The live path: the server rebroadcasts on every scan, so renderBoard runs
    // with the archive already open. Repainting the columns underneath would
    // kick the user out mid-read.
    handleJobsList({ jobs: [finishedJob()], settings: {} });
    document.getElementById('btn-finished-jobs').click();

    handleJobsList({ jobs: [finishedJob(), finishedJob({ id: 'done-2', doneAt: '2026-08-29T10:00:00Z' }), JOB()], settings: {} });
    expect(document.getElementById('job-finished').style.display).toBe('flex');
    expect(document.getElementById('job-columns').style.display).toBe('none');
    expect(finishedCards().map(c => c.dataset.jobId)).toEqual(['done-2', 'done-1']);
  });

  it('returns to the columns when a new job is posted from the archive', () => {
    // The new card lands in To do, which the archive is covering — posting from
    // here would otherwise look like nothing happened.
    handleJobsList({ jobs: [finishedJob()], settings: {} });
    document.getElementById('btn-finished-jobs').click();
    document.getElementById('btn-new-job').click();
    expect(document.getElementById('job-finished').style.display).toBe('none');
  });

  it('keeps the toggle tooltip and aria-pressed in step with its label', () => {
    handleJobsList({ jobs: [finishedJob()], settings: {} });
    const btn = document.getElementById('btn-finished-jobs');
    expect(btn.getAttribute('aria-pressed')).toBe('false');
    const boardTitle = btn.title;

    btn.click();
    expect(btn.getAttribute('aria-pressed')).toBe('true');
    expect(btn.title).not.toBe(boardTitle);
    expect(btn.title).toMatch(/columns/);
  });

  it('falls back to the columns when the archive markup is missing', () => {
    // A page cached from before the upgrade has the old markup and the new
    // module; hiding the columns for an archive that has no container would
    // leave a blank board.
    handleJobsList({ jobs: [finishedJob(), JOB()], settings: {} });
    document.getElementById('btn-finished-jobs').click();
    document.getElementById('job-finished').remove();
    renderBoard();
    expect(document.getElementById('job-columns').style.display).not.toBe('none');
    expect(columnCards('todo')).toHaveLength(1);
  });

  it('reopens on the board after the tab is left and returned to', () => {
    handleJobsList({ jobs: [finishedJob()], settings: {} });
    document.getElementById('btn-finished-jobs').click();
    hideJobBoard();
    showJobBoard();
    expect(document.getElementById('job-finished').style.display).toBe('none');
  });
});

describe('a finished card still shows its record', () => {
  it('shows the branch even when the agent name was lost', () => {
    // A restart used to null agentName, and the branch was rendered inside the
    // agent block — so the card came up with nothing on it but its title.
    handleJobsList({
      jobs: [JOB({ state: 'review', agentName: null, branchName: 'bill10/add-job-button', prUrl: 'https://gh/o/r/pull/18', prNumber: 18 })],
      settings: {},
    });
    const card = document.querySelector('.job-card');
    expect(card.querySelector('.job-card-branch').textContent).toBe('bill10/add-job-button');
    expect(card.querySelector('.job-card-agent-name')).toBeNull();
    expect(card.querySelector('.job-card-pr').textContent).toBe('PR #18');
  });

  it('shows the agent name when there is no branch', () => {
    handleJobsList({ jobs: [JOB({ state: 'review', agentName: 'Phantom', branchName: null })], settings: {} });
    const card = document.querySelector('.job-card');
    expect(card.querySelector('.job-card-agent-name').textContent).toBe('Phantom');
    expect(card.querySelector('.job-card-branch')).toBeNull();
  });

  it('shows both, with when it started', () => {
    handleJobsList({
      jobs: [JOB({ state: 'review', agentName: 'Phantom', branchName: 'bill10/x', startedAt: new Date().toISOString() })],
      settings: {},
    });
    const row = document.querySelector('.job-card-agent');
    expect(row.textContent).toMatch(/Phantom/);
    expect(row.textContent).toMatch(/started/);
    // The branch pairs with the repo, not with the agent.
    expect(document.querySelector('.job-card-where').textContent).toBe('alphabill10/x');
    expect(row.textContent).not.toMatch(/bill10\/x/);
  });

  it('pairs the repo with the branch on one line, ahead of the agent', () => {
    handleJobsList({ jobs: [JOB({ state: 'review', agentName: 'Phantom', branchName: 'bill10/x' })], settings: {} });
    const card = document.querySelector('.job-card');
    const where = card.querySelector('.job-card-where');
    expect(where.querySelector('.job-card-repo').textContent).toBe('alpha');
    expect(where.querySelector('.job-card-branch').title).toBe('Branch: bill10/x');
    expect(where.compareDocumentPosition(card.querySelector('.job-card-agent')) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
  });

  it('renders no agent row at all when neither is known', () => {
    handleJobsList({ jobs: [JOB({ state: 'todo', agentName: null, branchName: null })], settings: {} });
    expect(document.querySelector('.job-card-agent')).toBeNull();
  });
});

// --- Scheduled jobs ---

const SCHEDULED = (over = {}) => JOB({
  id: 'job-sched', title: 'Daily digest', type: 'scheduled', schedule: '0 9 * * 1-5',
  nextRunAt: new Date(Date.now() + 2 * 3600_000).toISOString(),
  runCount: 0, lastRunAt: null,
  ...over,
});

describe('scheduled cards', () => {
  it('marks the card and shows the cron with when it next fires', () => {
    handleJobsList({ jobs: [SCHEDULED()], settings: {} });
    const card = cards()[0];
    expect(card.querySelector('.job-card-type').textContent).toBe('recurring');
    expect(card.querySelector('.job-card-cron').textContent).toBe('0 9 * * 1-5');
    expect(card.querySelector('.job-card-next').textContent).toMatch(/^runs .*·.*in 2h/);
  });

  it('says a recurring schedule in words and keeps the cron in the tooltip', () => {
    handleJobsList({ jobs: [SCHEDULED({ scheduleWords: 'Weekdays at 9:00 AM' })], settings: {} });
    const cron = cards()[0].querySelector('.job-card-cron');
    expect(cron.textContent).toBe('Weekdays at 9:00 AM');
    expect(cron.title).toBe('0 9 * * 1-5');
  });

  it('shows a one-time card with a future start as scheduled, with its date, Run now and no Pause', () => {
    const at = new Date(Date.now() + 2 * 86400_000).toISOString();
    handleJobsList({ jobs: [JOB({ runAt: at })], settings: {} });
    const card = cards()[0];
    expect(card.querySelector('.job-card-type').textContent).toBe('scheduled');
    expect(card.querySelector('.job-card-cron')).toBeNull();
    expect(card.querySelector('.job-card-next').textContent).toMatch(/\d.* · in 2d/);
    const labels = [...card.querySelectorAll('.job-card-btn')].map(b => b.textContent);
    expect(labels).toEqual(expect.arrayContaining(['Edit', 'Run now', 'Archive', 'Delete']));
    expect(labels.some(l => /Pause|Resume/.test(l))).toBe(false);
    [...card.querySelectorAll('.job-card-btn')].find(b => b.textContent === 'Run now').click();
    expect(send).toHaveBeenCalledWith({ type: 'job-release-hold', jobId: card.dataset.jobId });
  });

  it('drops the scheduled look once the card has started', () => {
    const at = new Date(Date.now() + 2 * 86400_000).toISOString();
    handleJobsList({ jobs: [JOB({ runAt: at, state: 'in-progress' })], settings: {} });
    const card = cards()[0];
    expect([...card.querySelectorAll('.job-card-type')].some(c => c.textContent === 'scheduled')).toBe(false);
    expect(card.querySelector('.job-card-next')).toBeNull();
  });

  it('still offers Resume on an old paused once card', () => {
    handleJobsList({ jobs: [JOB({ runAt: new Date(Date.now() + 86400_000).toISOString(), paused: true })], settings: {} });
    expect([...document.querySelectorAll('.job-card-btn')].some(b => b.textContent === 'Resume')).toBe(true);
  });

  it('points at its latest run and says why it last held off', () => {
    handleJobsList({
      jobs: [
        SCHEDULED({ lastRunJobId: 'run-1', lastSkipReason: 'waiting on PR #7', lastSkipAt: new Date(Date.now() - 60_000).toISOString() }),
        JOB({ id: 'run-1', state: 'review', scheduleId: 'job-sched' }),
      ],
      settings: {},
    });
    const card = document.querySelector('[data-job-id="job-sched"]');
    expect(card.querySelector('.job-card-lastrun').textContent).toMatch(/latest run: Review/);
    expect(card.textContent).toMatch(/held off .*waiting on PR #7/);
    const run = document.querySelector('[data-job-id="run-1"]');
    expect([...run.querySelectorAll('.job-card-type')].map(c => c.textContent)).toContain('run');
  });

  it('says a run replaced earlier ones', () => {
    handleJobsList({ jobs: [JOB({ id: 'run-2', state: 'review', scheduleId: 's', supersededRuns: 3 })], settings: {} });
    expect(document.querySelector('[data-job-id="run-2"]').textContent).toMatch(/filed 3 earlier runs to Finished/);
  });

  it('says one replaced run in the singular', () => {
    handleJobsList({ jobs: [JOB({ id: 'run-2', state: 'review', scheduleId: 's', supersededRuns: 1 })], settings: {} });
    expect(document.querySelector('[data-job-id="run-2"]').textContent).toMatch(/filed 1 earlier run to Finished/);
  });

  it('marks an archived run that a newer one replaced', () => {
    handleJobsList({ jobs: [JOB({ id: 'run-1', state: 'done', scheduleId: 's', supersededBy: 'run-2', doneAt: new Date().toISOString() })], settings: {} });
    document.getElementById('btn-finished-jobs').click();
    expect(document.querySelector('#job-finished-cards [data-job-id="run-1"]').textContent).toMatch(/superseded by a newer run/);
  });

  it('calls a latest run that is done "Finished"', () => {
    handleJobsList({
      jobs: [SCHEDULED({ lastRunJobId: 'run-1' }), JOB({ id: 'run-1', state: 'done', scheduleId: 'job-sched', doneAt: new Date().toISOString() })],
      settings: {},
    });
    expect(document.querySelector('[data-job-id="job-sched"] .job-card-lastrun').textContent).toMatch(/latest run: Finished/);
  });

  it('says nothing about a latest run the board no longer has', () => {
    handleJobsList({ jobs: [SCHEDULED({ lastRunJobId: 'gone' })], settings: {} });
    expect(document.querySelector('.job-card-schedule').textContent).not.toMatch(/latest run/);
  });

  it('keeps a legacy held timestamp when new firing evidence was never recorded', () => {
    handleJobsList({ jobs: [SCHEDULED({ lastSkipReason: 'the previous run is still going', lastSkipAt: new Date().toISOString(),
      scheduleStatus: { label: 'Held: prior worker gone', attention: true, observedAt: null, graceMs: 300000, timeZone: 'UTC' },
    })], settings: {} });
    expect(document.querySelector('[data-job-id="job-sched"]').textContent).toMatch(/held off .*the previous run is still going/);
  });

  it('does not say it held off while it is paused', () => {
    handleJobsList({ jobs: [SCHEDULED({ paused: true, lastSkipReason: 'the previous run is still going', lastSkipAt: new Date().toISOString() })], settings: {} });
    expect(document.querySelector('[data-job-id="job-sched"]').textContent).not.toMatch(/held off/);
  });

  it('says so when the schedule will never come round again', () => {
    handleJobsList({ jobs: [SCHEDULED({ schedule: '0 0 30 2 *', nextRunAt: null })], settings: {} });
    expect(document.querySelector('.job-card-next').textContent).toMatch(/never fires again/);
  });

  it('shows the run count once it has run', () => {
    handleJobsList({ jobs: [SCHEDULED({ runCount: 3, lastRunAt: new Date(Date.now() - 3600_000).toISOString() })], settings: {} });
    expect(document.querySelector('.job-card-runs').textContent).toMatch(/ran 3.*last 1h ago/);
  });

  it('leaves a one-time card with no schedule row and no chip', () => {
    handleJobsList({ jobs: [JOB({ type: 'one-time' })], settings: {} });
    expect(document.querySelector('.job-card-schedule')).toBeNull();
    expect(document.querySelector('.job-card-type')).toBeNull();
  });

  it('offers Pause and sends the toggle', () => {
    handleJobsList({ jobs: [SCHEDULED()], settings: {} });
    const btn = () => [...document.querySelectorAll('.job-card-btn')].find(b => /Pause|Resume/.test(b.textContent));
    expect(btn().textContent).toBe('Pause');
    btn().click();
    expect(send).toHaveBeenCalledWith({ type: 'job-pause', jobId: 'job-sched', paused: true });
  });

  it('reads as paused on the card and offers Resume instead', () => {
    handleJobsList({ jobs: [SCHEDULED({ paused: true })], settings: {} });
    // The held due time is not shown: resuming re-arms from that moment, so a
    // countdown here would be a promise the board does not keep.
    expect(document.querySelector('.job-card-next').textContent).toBe('paused');
    const resume = [...document.querySelectorAll('.job-card-btn')].find(b => b.textContent === 'Resume');
    expect(resume).toBeTruthy();
    resume.click();
    expect(send).toHaveBeenCalledWith({ type: 'job-pause', jobId: 'job-sched', paused: false });
  });

  it('leaves a one-time card without a Pause button', () => {
    handleJobsList({ jobs: [JOB({ type: 'one-time' })], settings: {} });
    const labels = [...document.querySelectorAll('.job-card-btn')].map(b => b.textContent);
    expect(labels).not.toContain('Pause');
  });

  it('opens the latest run\'s agent from the schedule when it has one', () => {
    agents.set('s7', { name: 'Viper', state: 'WORKING', lastOutputAt: Date.now(), termEl: document.createElement('div') });
    handleJobsList({
      jobs: [SCHEDULED({ lastRunJobId: 'run-1' }), JOB({ id: 'run-1', state: 'in-progress', scheduleId: 'job-sched', agentSessionId: 's7', agentName: 'Viper' })],
      settings: {},
    });
    document.querySelector('[data-job-id="job-sched"] .job-card-lastrun').click();
    expect(switchToSession).toHaveBeenCalledWith('s7');
  });

  it('marks a schedule whose runs open PRs', () => {
    handleJobsList({ jobs: [SCHEDULED({ requiresPr: true })], settings: {} });
    expect([...document.querySelectorAll('.job-card-type')].map(c => c.textContent)).toContain('PR runs');
  });

  it('shows no move buttons on a schedule in any state', () => {
    for (const state of ['in-progress', 'review']) {
      handleJobsList({ jobs: [SCHEDULED({ state })], settings: {} });
      const labels = [...document.querySelectorAll('.job-card-btn')].map(b => b.textContent);
      expect(labels.filter(l => /Review|To do|Done|In progress/.test(l))).toEqual([]);
    }
  });

  it('offers a schedule no moves — its runs are the cards that move', () => {
    handleJobsList({ jobs: [SCHEDULED()], settings: {} });
    const labels = [...document.querySelectorAll('.job-card-btn')].map(b => b.textContent);
    expect(labels).not.toContain('→ Review');
    expect(labels).not.toContain('✓ Done');
    expect(labels).not.toContain('End run');
  });
});

describe('the job form and schedules', () => {
  const type = () => document.getElementById('job-type');
  const schedule = () => document.getElementById('job-schedule');
  const field = () => document.getElementById('job-schedule-field');

  it('hides the cron box until the job is a scheduled one', () => {
    document.getElementById('btn-new-job').click();
    expect(field().style.display).toBe('none');
    type().value = 'scheduled';
    type().dispatchEvent(new Event('change'));
    expect(field().style.display).toBe('flex');
  });

  it('posts the type and the schedule together', () => {
    document.getElementById('btn-new-job').click();
    document.getElementById('job-title').value = 'Daily digest';
    type().value = 'scheduled';
    type().dispatchEvent(new Event('change'));
    schedule().value = '0 9 * * 1-5';
    document.getElementById('btn-job-save').click();
    expect(send).toHaveBeenCalledWith(expect.objectContaining({
      type: 'job-create', title: 'Daily digest', jobType: 'scheduled', schedule: '0 9 * * 1-5',
    }));
  });

  it('Once at… shows a time box instead of the cron box and posts runAt as an instant', () => {
    document.getElementById('btn-new-job').click();
    document.getElementById('job-title').value = 'Follow up';
    type().value = 'once';
    type().dispatchEvent(new Event('change'));
    expect(field().style.display).toBe('none');
    expect(document.getElementById('job-run-at-field').style.display).toBe('flex');
    expect(document.getElementById('job-requires-pr').value).toBe('yes');
    const at = new Date(Date.now() + 3600_000);
    const p = n => String(n).padStart(2, '0');
    // the box holds local wall-clock time; the request carries the same instant
    document.getElementById('job-run-at').value = `${at.getFullYear()}-${p(at.getMonth() + 1)}-${p(at.getDate())}T${p(at.getHours())}:${p(at.getMinutes())}`;
    document.getElementById('btn-job-save').click();
    const msg = send.mock.calls.map(c => c[0]).find(m => m.type === 'job-create');
    expect(msg).toMatchObject({ jobType: 'one-time' });
    expect(msg).not.toHaveProperty('once');
    expect(Math.abs(Date.parse(msg.runAt) - at.getTime())).toBeLessThan(60_000);
  });

  it('Once at… needs a time that is in the future and within a year', () => {
    document.getElementById('btn-new-job').click();
    document.getElementById('job-title').value = 'Follow up';
    type().value = 'once';
    const err = () => document.getElementById('job-form-error').textContent;
    document.getElementById('btn-job-save').click();
    expect(err()).toMatch(/Pick the date/);
    document.getElementById('job-run-at').value = '2020-01-01T09:00';
    document.getElementById('btn-job-save').click();
    expect(err()).toMatch(/future/);
    document.getElementById('job-run-at').value = '2099-01-01T09:00';
    document.getElementById('btn-job-save').click();
    expect(err()).toMatch(/within the next year/);
    expect(send).not.toHaveBeenCalledWith(JOB_SAVE);
  });

  it('catches an obviously wrong cron while the form is still open', () => {
    // The server's parser is the authority; this only saves the user from
    // losing their text to a toast on the commonest typo.
    document.getElementById('btn-new-job').click();
    document.getElementById('job-title').value = 'Daily digest';
    type().value = 'scheduled';
    schedule().value = 'every friday';
    document.getElementById('btn-job-save').click();
    expect(send).not.toHaveBeenCalledWith(JOB_SAVE);
    expect(document.getElementById('job-form-error').textContent).toMatch(/cron schedule/i);
  });

  it('accepts the @shorthands', () => {
    document.getElementById('btn-new-job').click();
    document.getElementById('job-title').value = 'Hourly check';
    type().value = 'scheduled';
    schedule().value = '@hourly';
    document.getElementById('btn-job-save').click();
    expect(send).toHaveBeenCalledWith(expect.objectContaining({ jobType: 'scheduled', schedule: '@hourly' }));
  });

  it('insists on a schedule for a scheduled job', () => {
    document.getElementById('btn-new-job').click();
    document.getElementById('job-title').value = 'Daily digest';
    type().value = 'scheduled';
    document.getElementById('btn-job-save').click();
    expect(send).not.toHaveBeenCalledWith(JOB_SAVE);
    expect(document.getElementById('job-form-error').textContent).toMatch(/needs a cron schedule/i);
  });

  it('opens an existing scheduled card with its own values, cron box already showing', () => {
    handleJobsList({ jobs: [SCHEDULED()], settings: {} });
    document.querySelector('.job-card-btn').click();   // Edit
    expect(type().value).toBe('scheduled');
    expect(schedule().value).toBe('0 9 * * 1-5');
    expect(field().style.display).toBe('flex');
  });

  it('resets the form back to one-time for the next new job', () => {
    handleJobsList({ jobs: [SCHEDULED()], settings: {} });
    document.querySelector('.job-card-btn').click();   // Edit the scheduled card
    document.getElementById('btn-job-cancel').click();
    document.getElementById('btn-new-job').click();
    expect(type().value).toBe('one-time');
    expect(schedule().value).toBe('');
    expect(field().style.display).toBe('none');
  });
});


describe('an archived run whose PR was closed', () => {
  it('says the PR was closed without merging, not that it merged', () => {
    handleJobsList({ jobs: [JOB({ id: 'r', state: 'done', scheduleId: 's', prClosedAt: new Date().toISOString(), doneAt: new Date().toISOString() })], settings: {} });
    document.getElementById('btn-finished-jobs').click();
    const text = document.querySelector('[data-job-id="r"] .job-card-finished').textContent;
    expect(text).toMatch(/PR closed without merging/);
    expect(text).not.toMatch(/^merged/);
  });
});

describe('the run line on a To do card', () => {
  const runText = (id) => document.querySelector(`[data-job-id="${id}"] .job-card-run`)?.textContent;
  const at = (min) => new Date(Date.now() - min * 60_000).toISOString();

  it('says the board is stopped rather than promising a run', () => {
    handleJobsList({ jobs: [JOB()], settings: { running: false } });
    expect(runText('job-1')).toBe('queued · board stopped');
  });

  it('says "runs next" while the repo has a free slot, and the queue behind a full one', () => {
    agents.set('s1', { name: 'Viper', state: 'WORKING', lastOutputAt: Date.now(), termEl: document.createElement('div') });
    const busy = JOB({ id: 'busy', state: 'in-progress', agentSessionId: 's1', postedAt: at(90) });
    const queued = [1, 2, 3].map(i => JOB({ id: `q${i}`, postedAt: at(60 - i) }));
    handleJobsList({ jobs: [busy, ...queued], settings: { running: true, maxPerRepo: 2 } });
    expect(runText('q1')).toBe('runs next');
    expect(runText('q2')).toBe('runs when a slot frees');
    expect(runText('q3')).toBe('runs when a slot frees (1 ahead)');
    expect(runText('busy')).toBeUndefined();
  });

  it('gives a scheduled card its start time', () => {
    handleJobsList({ jobs: [JOB({ runAt: new Date(Date.now() + 2 * 3600_000).toISOString() })], settings: { running: true } });
    expect(runText('job-1')).toMatch(/^runs .*\d.* · in 2h$/);
  });
});

describe('what each column says first', () => {
  const order = (card) => [...card.children].map(el => el.className.split(' ')[0]);

  it('a To do card: title, repo, when it runs, who posted it, then the chips', () => {
    handleJobsList({ jobs: [JOB({ model: 'opus', requiresPr: false })], settings: { running: true } });
    expect(order(cards()[0]).slice(0, 5)).toEqual(['job-card-title', 'job-card-where', 'job-card-run', 'job-card-meta', 'job-card-chips']);
  });

  it('an In progress card: title, status, repo and branch, agent', () => {
    agents.set('s1', { name: 'Viper', state: 'WORKING', lastOutputAt: Date.now(), termEl: document.createElement('div') });
    handleJobsList({ jobs: [JOB({ state: 'in-progress', agentSessionId: 's1', agentName: 'Viper', branchName: 'b', startedAt: new Date().toISOString() })], settings: {} });
    expect(order(cards()[0]).slice(0, 4)).toEqual(['job-card-title', 'job-card-state', 'job-card-where', 'job-card-agent']);
  });

  it('a Review card: title, PR, repo and branch, agent, the agent·model pill, then the summary', () => {
    handleJobsList({ jobs: [JOB({ state: 'review', prUrl: 'https://gh/o/r/pull/3', prNumber: 3, agentName: 'Echo', branchName: 'b', model: 'opus', resultSummary: 'Done.' })], settings: {} });
    const card = cards()[0];
    expect(order(card).slice(0, 6)).toEqual(['job-card-title', 'job-card-state', 'job-card-where', 'job-card-agent', 'job-card-chips', 'job-card-result']);
    expect(card.querySelector('.job-card-chips').firstElementChild.textContent).toBe('claude · opus');
  });
});