test/client-jobs-via-agent.test.js
Rama main · réplica de lectura del repositorio original.
// @vitest-environment happy-dom
//
// A card an agent posted has to be distinguishable at a glance from one a
// person typed into the form — that is the whole point of storing postedByAgent
// separately from postedByName. The meta line joins up to three facts with
// ' · ', and any of the first two can be missing.
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../public/modules/ws.js', () => ({ send: vi.fn(() => true) }));
vi.mock('../public/modules/terminal.js', () => ({ switchToSession: vi.fn(), restartAgent: vi.fn(), restartPending: () => false }));
import { jobs, agents, repos, setActiveSession } from '../public/modules/state.js';
import { handleJobsList, showJobBoard, setupJobBoard } from '../public/modules/jobs.js';
const BOARD_HTML = `
<button id="btn-new-job-shortcut"></button>
<div class="spawn-form" id="spawn-form" style="display:none"></div>
<div id="terminal-viewport">
<div id="terminal-empty"></div>
<div class="job-board" id="job-board" style="display:none">
<div class="job-board-toolbar">
<button id="btn-new-job"></button>
<button id="btn-dispatcher-toggle"></button>
<button id="btn-dispatch-now"></button>
<span id="job-dispatcher-status"></span>
<input type="number" id="job-max-per-repo">
</div>
<div class="job-form" id="job-form" style="display:none">
<input type="text" id="job-title">
<select id="job-repo"></select>
<select id="job-type">
<option value="one-time">Now</option>
<option value="once">Scheduled</option>
<option value="scheduled">Recurring</option>
</select>
<div id="job-schedule-field" style="display:none">
<input type="text" id="job-schedule">
</div>
<div id="job-run-at-field" style="display:none">
<input type="datetime-local" id="job-run-at">
</div>
<textarea id="job-detail"></textarea>
<button id="btn-job-save"></button>
<button id="btn-job-cancel"></button>
<div id="job-form-error" style="display:none"></div>
</div>
<div class="job-columns" id="job-columns"></div>
</div>
</div>`;
const JOB = (over = {}) => ({
id: 'job-1', title: 'Add rate limiting', detail: 'Token bucket.',
repoPath: '/repos/alpha', state: 'todo',
postedByName: null, postedByAgent: null, postedAt: new Date().toISOString(),
agentSessionId: null, agentName: null, startedAt: null,
branchName: null, prUrl: null, prNumber: null, lastError: 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();
});
const postedLine = () => document.querySelector('.job-card-posted');
describe('a card an agent posted', () => {
it('names the person and the agent, in that order', () => {
handleJobsList({ jobs: [JOB({ postedByName: 'Bill', postedByAgent: 'Onyx' })], settings: {} });
const text = postedLine().textContent;
expect(text).toContain('Bill');
expect(text).toContain('via Onyx');
expect(text.indexOf('Bill')).toBeLessThan(text.indexOf('via Onyx'));
});
it('tints the agent so a machine-queued card reads differently', () => {
handleJobsList({ jobs: [JOB({ postedByName: 'Bill', postedByAgent: 'Onyx' })], settings: {} });
expect(postedLine().querySelector('.job-card-via').textContent).toBe('via Onyx');
});
it('names the agent as the author when no human owns the terminal yet', () => {
// Auth is off by default, so a session has no owner and postedByName is
// null: the card reads "posted by Mirage", not "via Mirage" with no one
// in front of it.
handleJobsList({ jobs: [JOB({ postedByAgent: 'Mirage' })], settings: {} });
const text = postedLine().textContent;
expect(text.startsWith('posted by Mirage · ')).toBe(true);
expect(text).not.toContain('via');
});
it('says who posted it in words', () => {
handleJobsList({ jobs: [JOB({ postedByName: 'Bill', postedByAgent: 'Onyx' })], settings: {} });
expect(postedLine().textContent.startsWith('posted by Bill via Onyx · ')).toBe(true);
});
it('says when an agent rewrote the card, not only who queued it', () => {
// An edit through the board's edit_job tool replaces the detail that
// becomes the next agent's prompt. The card must not go on reading as
// though the person who queued it wrote what is there now.
handleJobsList({ jobs: [JOB({
postedByName: 'Bill', editedByAgent: 'Onyx', editedAt: new Date().toISOString(),
})], settings: {} });
const text = postedLine().textContent;
expect(text).toContain('Bill');
expect(text).toContain('edited by Onyx');
});
it('escapes the name of an agent that edited, the same as one that posted', () => {
handleJobsList({ jobs: [JOB({
editedByAgent: '<img src=x onerror=alert(1)>', editedAt: new Date().toISOString(),
})], settings: {} });
expect(postedLine().querySelector('img')).toBeNull();
expect(postedLine().textContent).toContain('<img src=x onerror=alert(1)>');
});
it('escapes an agent name rather than rendering it as markup', () => {
handleJobsList({ jobs: [JOB({ postedByAgent: '<img src=x onerror=alert(1)>' })], settings: {} });
expect(postedLine().querySelector('img')).toBeNull();
expect(postedLine().textContent).toContain('<img src=x onerror=alert(1)>');
});
});
describe('a card a person typed', () => {
it('is unchanged — no "via", no stray separator', () => {
handleJobsList({ jobs: [JOB({ postedByName: 'Bill' })], settings: {} });
const text = postedLine().textContent;
expect(text).toContain('Bill');
expect(text).not.toContain('via');
expect(postedLine().querySelector('.job-card-via')).toBeNull();
});
it('shows only the time when nothing else is known', () => {
handleJobsList({ jobs: [JOB()], settings: {} });
expect(postedLine().textContent).not.toContain('·');
});
});