// Stream S4 (PLAN C5): GET /api/themes lists the installed, valid themes and nothing else. import test from 'node:test'; import assert from 'node:assert/strict'; import { startServer } from '../src/serve.mjs'; import { themesHTTP } from '../src/themes-http.mjs'; import { close } from './fixture.mjs'; const IDS = ['harbor', 'carmine', 'atlantic', 'terracotta', 'aubergine', 'mineral', 'cobalt', 'rosewood', 'graphite', 'grove'].sort(); const TOKENS = ['--canvas', '--surface', '--raised', '--line', '--border', '--text', '--muted', '--action', '--onAction', '--accent', '--focus', '--success', '--warning', '--danger']; const reply = () => { const out = { status: null, headers: null, text: '' }; return { out, res: { writeHead(status, headers) { out.status = status; out.headers = headers; }, end(text) { out.text = text; } } }; }; test('S4 GET /api/themes on a running Console returns the ten shipped themes, each with three modes of fourteen colours', async t => { const server = await startServer({ port: 0 }); t.after(() => close(server)); const response = await fetch(`http://127.0.0.1:${server.address().port}/api/themes`); assert.equal(response.status, 200); assert.match(response.headers.get('content-type'), /^application\/json/); assert.equal(response.headers.get('cache-control'), 'no-store'); const body = await response.json(); assert.deepEqual(Object.keys(body), ['themes']); assert.deepEqual(body.themes.map(x => x.id), IDS); for (const theme of body.themes) { assert.deepEqual(Object.keys(theme).sort(), ['displayName', 'id', 'tokens'], theme.id); assert.deepEqual(Object.keys(theme.tokens), ['light', 'dim', 'dark'], theme.id); for (const mode of ['light', 'dim', 'dark']) { assert.deepEqual(Object.keys(theme.tokens[mode]), TOKENS, `${theme.id} ${mode}`); assert.ok(Object.values(theme.tokens[mode]).every(v => /^#[0-9a-f]{6}$/.test(v)), `${theme.id} ${mode}`); } } assert.equal(body.themes.find(x => x.id === 'harbor').displayName, 'Harbor'); // Only a read: other methods are refused, and a query string changes nothing. assert.equal((await fetch(`http://127.0.0.1:${server.address().port}/api/themes`, { method: 'POST', body: '{}' })).status, 405); assert.deepEqual((await (await fetch(`http://127.0.0.1:${server.address().port}/api/themes?theme=../../etc`)).json()).themes.map(x => x.id), IDS); }); test('S4 a refused module is left out of the answer and logged once per distinct reason, not once per request', async () => { const refusal = { event: 'plugin-refused', kind: 'theme', name: 'broken', code: 'bad-theme', message: 'theme broken: tokens.light --canvas "url(x)" must be a #rrggbb colour' }; const good = { id: 'ok', displayName: 'Ok', tokens: { light: {}, dim: {}, dark: {} }, extra: 'dropped' }; const lines = []; const seen = new Set(); const list = async ({ log }) => { log(refusal); return { themes: [good], refused: [refusal] }; }; for (let i = 0; i < 3; i++) { const { res, out } = reply(); await themesHTTP({ method: 'GET' }, res, { log: line => lines.push(line), list, seen }); assert.equal(out.status, 200); assert.deepEqual(JSON.parse(out.text), { themes: [{ id: 'ok', displayName: 'Ok', tokens: { light: {}, dim: {}, dark: {} } }] }); } assert.equal(lines.length, 1); assert.match(lines[0], /theme refused name="broken" code="bad-theme" message="theme broken: /); // A different reason for the same module is logged again. await themesHTTP({ method: 'GET' }, reply().res, { log: line => lines.push(line), seen, list: async ({ log }) => { log({ ...refusal, code: 'load-failed', message: 'import failed' }); return { themes: [], refused: [] }; } }); assert.equal(lines.length, 2); }); test('S4 a failure to read the themes is a 500 that logs the cause and tells the browser nothing about the machine', async () => { const lines = []; const { res, out } = reply(); await themesHTTP({ method: 'GET' }, res, { log: line => lines.push(line), list: async () => { throw new Error('EACCES: /home/someone/secret/path'); } }); assert.equal(out.status, 500); assert.deepEqual(JSON.parse(out.text), { error: 'themes-unavailable', message: 'the installed themes could not be read' }); assert.ok(!out.text.includes('/home/')); assert.match(lines[0], /theme list failed/); const other = reply(); await themesHTTP({ method: 'DELETE' }, other.res, { list: async () => assert.fail('no read on a write method') }); assert.equal(other.out.status, 405); });