// @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'); }); });