// @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 = `
`;
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: '' })], settings: {} });
const title = cards()[0].querySelector('.job-card-title');
expect(title.querySelector('img')).toBeNull();
expect(title.textContent).toBe('');
});
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,', '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');
});
});