test/client-settings-version.test.js
Rama main · réplica de lectura del repositorio original.
// @vitest-environment happy-dom
import { describe, it, expect, vi } from 'vitest';
vi.mock('../public/modules/ws.js', () => ({ send: vi.fn(() => true) }));
import { renderVersion, renderNotes, renderNotesBody, renderSkillStore } from '../public/modules/settings.js';
import { readFileSync } from 'fs';
import { join } from 'path';
const text = (html) => { const d = document.createElement('div'); d.innerHTML = html; return d; };
describe('Settings version line', () => {
it('shows the version, a newer one and Update', () => {
const d = text(renderVersion({ version: '0.53.5.0', kind: 'npm', latest: '0.54.0.0' }));
expect(d.textContent).toContain('Agent 007 0.53.5.0');
expect(d.textContent).toContain('Version 0.54.0.0 is available');
expect(d.querySelector('[data-update="start"]').textContent).toBe('Update');
expect(d.querySelector('[data-update="notes"]').textContent).toBe('What’s new');
expect(text(renderVersion({ version: '1.0.0.0', kind: 'npm' })).querySelector('[data-update="notes"]')).toBeNull();
expect(text(renderVersion({ version: '1.0.0.0', kind: 'npm', latest: '2.0.0.0' }, 'updating')).querySelector('[data-update="notes"]')).toBeNull();
});
it('shows who Cloudflare Access let in, as text', () => {
expect(text(renderVersion({ version: '1.0.0.0', kind: 'npm', accessEmail: '<b>ada@example.com</b>' })).textContent).toContain('Signed in through Cloudflare Access as <b>ada@example.com</b>');
expect(text(renderVersion({ version: '1.0.0.0', kind: 'npm' })).textContent).not.toContain('Cloudflare');
});
it('offers Check for updates, and says what it found', () => {
const cur = { version: '1.0.0.0', kind: 'npm' };
expect(text(renderVersion(cur)).querySelector('[data-update="check"]').textContent).toBe('Check for updates');
expect(text(renderVersion({ ...cur, latest: '2.0.0.0' })).querySelector('[data-update="check"]')).not.toBeNull();
const busy = text(renderVersion(cur, null, 'checking'));
expect(busy.textContent).toContain('Checking…');
expect(busy.querySelector('[data-update="check"]')).toBeNull();
expect(text(renderVersion(cur, null, 'checked')).textContent).toContain('Up to date (version 1.0.0.0)');
const newer = text(renderVersion({ ...cur, latest: '2.0.0.0' }, null, 'checked'));
expect(newer.textContent).toContain('Version 2.0.0.0 is available');
expect(newer.textContent).not.toContain('Up to date');
const failed = text(renderVersion({ ...cur, checkFailed: true }, null, 'checked'));
expect(failed.textContent).toContain('The check failed');
expect(failed.textContent).not.toContain('Up to date');
expect(failed.querySelector('[data-update="check"]')).not.toBeNull();
expect(text(renderVersion(cur, 'updating')).querySelector('button')).toBeNull();
});
it('shows no check button for npx', () => {
const npx = text(renderVersion({ version: '1.0.0.0', kind: 'npx', latest: '2.0.0.0' }));
expect(npx.textContent).toContain('npx runs the latest each start');
expect(npx.querySelector('button')).toBeNull();
});
it('shows each step of an update', () => {
const info = { version: '1.0.0.0', kind: 'npm', latest: '2.0.0.0' };
expect(text(renderVersion(info, 'updating')).textContent).toContain('Updating…');
expect(text(renderVersion({ ...info, updating: { waiting: 3 } }, 'updating')).textContent).toContain('Waiting for 3 busy workers…');
expect(text(renderVersion(info, 'restarting')).textContent).toContain('Restarting…');
expect(text(renderVersion({ version: '2.0.0.0', kind: 'npm' }, 'done')).textContent).toContain('Updated to 2.0.0.0');
const failed = text(renderVersion({ ...info, finished: { code: 1, log: 'EACCES <b>' } }, 'failed'));
expect(failed.querySelector('.settings-version-error').textContent).toBe('EACCES <b>');
expect(failed.textContent).toContain('agent007 update');
expect(failed.querySelector('[data-update="start"]')).toBeNull();
});
});
describe('What’s new', () => {
it('renders the CHANGELOG’s markdown, escaped', () => {
const d = text(renderNotes([
'## [2.0.0.0] - 2026-10-08', '', '### Added', '',
'- **Bold.** with `code <x>` and [a link](https://example.com/a) and [bad](javascript:alert(1))',
' - nested', '- second', '', 'A paragraph <script>.',
].join('\n')));
expect(d.querySelector('h3').textContent).toBe('2.0.0.0 2026-10-08');
expect(d.querySelector('h4').textContent).toBe('Added');
expect(d.querySelector('li strong').textContent).toBe('Bold.');
expect(d.querySelector('li code').textContent).toBe('code <x>');
expect(d.querySelectorAll('a')).toHaveLength(1);
expect(d.querySelector('a').getAttribute('href')).toBe('https://example.com/a');
expect(d.querySelector('li ul li').textContent).toBe('nested');
expect(d.querySelectorAll(':scope > ul > li')).toHaveLength(2);
expect(d.querySelector('p').textContent).toBe('A paragraph <script>.');
expect(d.querySelector('script')).toBeNull();
});
// Value: protects=bold or code that wraps onto an item's next line still renders; fails_when=inline runs line by line again; why_new=the table above has one-line items only; seam=none
it('formats bold and code that wrap onto the next line', () => {
const d = text(renderNotes('- **Billion hears when CI\n finishes.** Runs `npm\n test`.\n\nA **para\nwraps**.'));
expect(d.querySelector('li strong').textContent).toBe('Billion hears when CI finishes.');
expect(d.querySelector('li code').textContent).toBe('npm test');
expect(d.querySelector('p strong').textContent).toBe('para wraps');
expect(d.textContent).not.toContain('**');
});
// Value: protects=the real CHANGELOG rendering as headings and lists, not raw markdown; fails_when=renderNotes stops matching the CHANGELOG's actual heading or list syntax; why_new=the test above uses a small hand-written sample; seam=none
it('renders the repo’s own CHANGELOG as headings and lists', () => {
const log = readFileSync(join(__dirname, '../CHANGELOG.md'), 'utf8');
const md = log.slice(log.search(/^## \[/m)); // the sections, as the server sends them
const d = text(renderNotes(md));
expect(d.querySelectorAll('h3')).toHaveLength(md.match(/^## \[/gm).length);
expect(d.querySelector('h3').textContent).toMatch(/^\d+\.\d+\.\d+\.\d+ \d{4}-\d\d-\d\d$/);
expect(d.querySelectorAll('li').length).toBe(md.match(/^\s*[-*] /gm).length);
for (const el of d.querySelectorAll('h3, h4, li, p')) expect(el.textContent).not.toMatch(/^#|\*\*/);
});
it('says when the notes could not load, and links to GitHub', () => {
const d = text(renderNotesBody({ error: 'Could not load the release notes from GitHub.', url: 'https://github.com/x/CHANGELOG.md' }));
expect(d.textContent).toContain('Could not load');
expect(d.querySelector('a').getAttribute('href')).toBe('https://github.com/x/CHANGELOG.md');
expect(text(renderNotesBody(null)).querySelector('a')).not.toBeNull();
});
});
describe('Settings agent CLI versions', () => {
it('shows each CLI\'s version and path, a newer one with Update, and what Restart has to do with it', async () => {
const { renderClis } = await import('../public/modules/settings.js');
const agents = ['claude', 'codex', 'gemini'].map(cli => ({ cli, version: `${cli}-raw 1`, path: `/bin/${cli}`, accounts: [] }));
const d = text(renderClis(agents, { claude: { version: '2.1.295', latest: '2.1.300' }, codex: { version: '0.157.0', latest: '0.157.0' } }));
expect(d.textContent).toContain('Claude Code 2.1.295 · /bin/claude · Update to 2.1.300');
expect(d.querySelector('[data-cli-update]').title).toContain('Running agents pick it up on their next Restart.');
expect(d.textContent).toContain('Codex 0.157.0 · /bin/codex');
expect(d.textContent).toContain('Gemini CLI gemini-raw 1 · /bin/gemini');
expect([...d.querySelectorAll('[data-cli-update]')].map(b => b.dataset.cliUpdate)).toEqual(['claude']);
const codex = [agents[1]];
expect(text(renderClis(codex, { codex: { version: '0.157.0', updating: true } })).textContent).toContain('Updating…');
expect(text(renderClis(codex, { codex: { version: '0.158.0', finished: { code: 0, log: '' } } })).textContent).toContain('Updated. Running agents pick it up on their next Restart.');
const failed = text(renderClis(codex, { codex: { version: '0.157.0', finished: { code: 1, log: '<b>EACCES</b>' } } }));
expect(failed.querySelector('.settings-version-error').textContent).toBe('<b>EACCES</b>');
expect(failed.textContent).toContain('codex update');
});
});
describe('Settings One skill store', () => {
it('shows the dry run with Turn on and Cancel before it goes on', () => {
const d = text(renderSkillStore({ enabled: false, last: null, preview: { summary: 'Would move 3 skills into the store.' } }));
expect(d.textContent).toContain('Dry run, nothing changed yet: Would move 3 skills into the store.');
expect(d.querySelector('[data-store="confirm"]').textContent).toBe('Turn on');
expect(d.querySelector('[data-store="cancel"]').textContent).toBe('Cancel');
});
it('shows the last sync, its backup, errors as text, and nothing while off and never run', () => {
const d = text(renderSkillStore({ enabled: true, last: { at: '2026-10-09T12:00:00Z', backup: '/h/.agent-007/skill-backup/x' }, home: '/h', summary: '<b>Moved</b> 1 skill.' }));
expect(d.textContent).toContain('<b>Moved</b> 1 skill.');
expect(d.textContent).toContain('Backup: ~/.agent-007/skill-backup/x');
expect(renderSkillStore({ enabled: false, last: null })).toBe('');
expect(text(renderSkillStore({ enabled: true, last: null })).textContent).toBe('No sync yet.');
expect(text(renderSkillStore({ error: '<i>nope</i>' })).textContent).toBe('<i>nope</i>');
});
});