Files
Haven/test/themeCompatibility.test.js

936 lines
36 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
const Database = require('better-sqlite3');
const express = require('express');
const {
THEME_API_VERSION,
classifyThemeApi,
compatibleThemeFiles,
createThemeFileMiddleware,
readThemeMetadataFile,
readThemeMetadataSnapshot,
parseThemeMetadata,
themeFileFromRequestPath,
validatedThemeDefault,
} = require('../src/themeMetadata');
const registerAdmin = require('../src/socketHandlers/admin');
const ThemeCompat = require('../public/js/theme-compat');
const ROOT = path.join(__dirname, '..');
const THEME_INIT_SOURCE = fs.readFileSync(path.join(ROOT, 'public/js/theme-init.js'), 'utf8');
const PLUGIN_LOADER_SOURCE = fs.readFileSync(path.join(ROOT, 'public/js/plugin-loader.js'), 'utf8');
class MemoryStorage {
constructor(initial = {}) {
this.values = new Map(Object.entries(initial));
}
getItem(key) { return this.values.has(key) ? this.values.get(key) : null; }
setItem(key, value) { this.values.set(key, String(value)); }
removeItem(key) { this.values.delete(key); }
}
function createDocument() {
const attributes = new Map();
const links = [];
const listeners = new Map();
const documentElement = {
attributes,
style: {
setProperty() {},
removeProperty() {},
},
classList: { add() {} },
setAttribute(name, value) { attributes.set(name, String(value)); },
removeAttribute(name) { attributes.delete(name); },
hasAttribute(name) { return attributes.has(name); },
};
const document = {
readyState: 'complete',
body: null,
documentElement,
head: {
appendChild(node) {
links.push(node);
if (typeof node.onload === 'function') Promise.resolve().then(() => node.onload());
},
},
createElement(tag) { return { tagName: tag.toUpperCase(), remove() {} }; },
getElementById() { return null; },
querySelectorAll() { return []; },
addEventListener(type, listener) {
if (!listeners.has(type)) listeners.set(type, new Set());
listeners.get(type).add(listener);
},
dispatchEvent(event) {
for (const listener of listeners.get(event.type) || []) listener(event);
},
};
return { document, documentElement, links };
}
function browserCompat(localStorage, sessionStorage) {
return {
...ThemeCompat,
cacheThemes: themes => ThemeCompat.cacheThemes(themes, localStorage),
getCachedTheme: file => ThemeCompat.getCachedTheme(file, localStorage),
fetchThemes: fetcher => ThemeCompat.fetchThemes(fetcher, true),
isSafeMode: location => ThemeCompat.isSafeMode(location, sessionStorage),
clearSafeMode: () => ThemeCompat.clearSafeMode(sessionStorage),
markResetPending: () => ThemeCompat.markResetPending(sessionStorage),
isResetPending: () => ThemeCompat.isResetPending(sessionStorage),
clearResetPending: () => ThemeCompat.clearResetPending(sessionStorage),
resetLocalCustomizations: () => ThemeCompat.resetLocalCustomizations(localStorage, sessionStorage),
};
}
function runThemeInit({ theme = 'file:test.theme.css', search = '', cache = [], themes, stored = {} } = {}) {
const localStorage = new MemoryStorage({ haven_theme: theme, ...stored });
const sessionStorage = new MemoryStorage();
ThemeCompat.cacheThemes(cache, localStorage);
const { document, documentElement, links } = createDocument();
const location = { search, href: `https://haven.test/${search}` };
const window = {
HavenThemeCompat: browserCompat(localStorage, sessionStorage),
location,
scrollX: 0,
scrollY: 0,
scrollTo() {},
addEventListener() {},
visualViewport: null,
};
const fetches = [];
if (themes !== undefined) {
window.fetch = async url => {
fetches.push(url);
return { ok: true, json: async () => themes };
};
}
vm.runInNewContext(THEME_INIT_SOURCE, {
URLSearchParams,
document,
history: {},
localStorage,
setTimeout() { return 1; },
window,
});
return { documentElement, fetches, links, localStorage, sessionStorage };
}
function createAdminThemeHarness() {
const db = new Database(':memory:');
db.exec('CREATE TABLE server_settings (key TEXT PRIMARY KEY, value TEXT)');
const handlers = new Map();
const outgoing = [];
const socket = {
user: { id: 1, isAdmin: true },
on(event, handler) { handlers.set(event, handler); },
emit(event, payload) { outgoing.push({ event, payload }); },
};
const io = {
sockets: { sockets: new Map() },
of() { return { sockets: new Map() }; },
except() { return { emit() {} }; },
};
registerAdmin(socket, {
io,
db,
state: { channelUsers: new Map() },
userHasPermission: () => true,
getUserEffectiveLevel: () => 100,
getUserPermissions: () => [],
getUserRoles: () => [],
getUserHighestRole: () => null,
emitOnlineUsers() {},
broadcastChannelLists() {},
generateUniqueSharedCode: () => '12345678',
logAudit() {},
fireWebhookEvent() {},
onReferrerPolicyChange() {},
automod: { invalidate() {}, settings: () => ({}) },
getIdleOnlineUsers: () => [],
getUploadUsage: () => ({ byUser: new Map() }),
getUserUploadMb: () => 25,
revokeBotVoiceAccess() {},
});
return { db, handlers, outgoing };
}
function listen(app) {
return new Promise(resolve => {
const server = app.listen(0, '127.0.0.1', () => resolve(server));
});
}
async function runPluginLoader({
safe = false,
theme = 'haven',
enabledPlugins = [],
enabledThemes = [],
plugins = [],
themes = [],
themeResponses = null,
themeFetchError = false,
withCompat = true,
search = '',
socket = null,
recoveryPending = false,
// 'admin' or 'member' puts the app on the page with that viewer; without
// one the app has not started yet.
viewer = null,
} = {}) {
const localStorage = new MemoryStorage({
haven_theme: theme,
haven_enabled_plugins: JSON.stringify(enabledPlugins),
haven_enabled_themes: JSON.stringify(enabledThemes),
});
const sessionStorage = new MemoryStorage({
...(safe ? { haven_safe_mode: '1' } : {}),
...(recoveryPending ? { haven_customizations_reset_pending: '1' } : {}),
});
const { document, documentElement, links } = createDocument();
const fetches = [];
const location = { search, href: `https://haven.test/app.html${search}`, assign() {} };
const window = {
location,
confirm() { return false; },
addEventListener() {},
havenSocket: socket,
};
if (viewer) window.app = { _canPublishThemes: () => viewer === 'admin' };
if (withCompat) window.HavenThemeCompat = browserCompat(localStorage, sessionStorage);
let themeFetchCount = 0;
const fetch = async url => {
fetches.push(url);
if (url === '/api/plugins') {
return { ok: true, json: async () => plugins };
}
if (url === '/api/themes') {
if (themeFetchError) throw new Error('temporary network failure');
const responseThemes = themeResponses
? themeResponses[Math.min(themeFetchCount++, themeResponses.length - 1)]
: themes;
return { ok: true, json: async () => responseThemes };
}
throw new Error(`unexpected extension fetch: ${url}`);
};
vm.runInNewContext(PLUGIN_LOADER_SOURCE, {
applyPublishedThemeBase(file, persist = true) {
const link = document.createElement('link');
link.id = `haven-theme-${file}`;
link.href = `/themes/${encodeURIComponent(file)}`;
document.head.appendChild(link);
if (persist) localStorage.setItem('haven_theme', `file:${file}`);
return link;
},
console,
CustomEvent: class CustomEvent {
constructor(type, init = {}) { this.type = type; this.detail = init.detail; }
},
document,
fetch,
globalThis: window,
localStorage,
location,
sessionStorage,
setTimeout(fn) { fn(); return 1; },
t(key) { return key; },
URLSearchParams,
window,
});
await new Promise(resolve => setImmediate(resolve));
await new Promise(resolve => setImmediate(resolve));
return {
api: window.HavenApi,
document,
documentElement,
fetches,
links,
localStorage,
loader: window.HavenPluginLoader,
};
}
test('structural layout ownership is exclusive and releases cleanly', async () => {
const result = await runPluginLoader();
assert.equal(result.api.Layout.acquire('FirstLayout'), true);
assert.equal(result.api.Layout.owner, 'FirstLayout');
assert.equal(result.documentElement.attributes.get('data-haven-layout-owner'), 'FirstLayout');
assert.equal(result.api.Layout.acquire('SecondLayout'), false);
assert.equal(result.api.Layout.release('SecondLayout'), false);
result.api.Layout._reserve('FirstLayout');
assert.equal(result.api.Layout.release('FirstLayout'), true);
assert.equal(result.api.Layout.owner, null);
assert.equal(result.documentElement.attributes.has('data-haven-layout-owner'), false);
assert.equal(result.api.Layout.acquire('SecondLayout'), false);
assert.equal(result.api.Layout.acquire('FirstLayout'), true);
assert.equal(result.api.Layout._clearReservation('FirstLayout'), true);
assert.equal(result.api.Layout.release('FirstLayout'), true);
});
test('theme metadata classifies current, legacy, future, and invalid API declarations', () => {
assert.equal(THEME_API_VERSION, 1);
assert.deepEqual(classifyThemeApi(null), {
themeApi: null,
themeApiDeclared: null,
compatibility: 'legacy',
compatible: true,
});
assert.equal(classifyThemeApi('1').compatibility, 'compatible');
assert.equal(classifyThemeApi('2').compatibility, 'unsupported');
assert.equal(classifyThemeApi('').compatibility, 'invalid');
assert.equal(classifyThemeApi('1.0').compatibility, 'invalid');
assert.equal(classifyThemeApi('0').compatibility, 'invalid');
assert.equal(classifyThemeApi('999999999999999999999').compatibility, 'invalid');
});
test('an empty Theme API metadata tag is invalid rather than legacy', () => {
const meta = parseThemeMetadata('/**\n * @name Empty API\n * @haven-theme-api\n */');
assert.equal(meta.compatibility, 'invalid');
assert.equal(meta.compatible, false);
assert.equal(meta.themeApiDeclared, '');
});
test('Theme API text inside a description is not treated as a declaration', () => {
const meta = parseThemeMetadata(`/**
* @description Uses @haven-theme-api 1 when available
* @haven-theme-api 2
*/`);
assert.equal(meta.description, 'Uses @haven-theme-api 1 when available');
assert.equal(meta.themeApi, 2);
assert.equal(meta.compatibility, 'unsupported');
});
test('an unknown metadata tag does not contaminate the Theme API value', () => {
const meta = parseThemeMetadata(`/**
* @haven-theme-api 1
* @website https://example.test
*/`);
assert.equal(meta.themeApi, 1);
assert.equal(meta.compatibility, 'compatible');
});
test('duplicate Theme API declarations are invalid', () => {
const meta = parseThemeMetadata(`/**
* @haven-theme-api 1
* @haven-theme-api 2
*/`);
assert.equal(meta.themeApi, null);
assert.equal(meta.themeApiDeclared, '1, 2');
assert.equal(meta.compatibility, 'invalid');
assert.equal(meta.compatible, false);
});
test('theme metadata parser preserves existing fields and exposes compatibility', () => {
const meta = parseThemeMetadata(`/**
* @name Test Theme
* @description A test theme
* @author Tester
* @version 2.3
* @icon T
* @haven-theme-api 1
*/
:root { --accent: red; }`);
assert.deepEqual(meta, {
name: 'Test Theme',
description: 'A test theme',
author: 'Tester',
version: '2.3',
icon: 'T',
themeApi: 1,
themeApiDeclared: '1',
compatibility: 'compatible',
compatible: true,
palette: false,
swatch: null,
});
});
test('theme metadata parser reads optional section and category fields', () => {
const meta = parseThemeMetadata(`/**
* @name Christmas
* @section Seasonal
* @category Holiday
* @haven-theme-api 1
*/
:root { --accent: red; }`);
assert.equal(meta.section, 'Seasonal');
assert.equal(meta.category, 'Holiday');
});
test('seasonal themes are grouped by their section or category', () => {
for (const theme of [{ section: 'Seasonal' }, { category: 'holiday' }]) assert.equal(ThemeCompat.isSeasonalTheme(theme), true);
for (const theme of [{ section: 'Featured' }, { file: 'my-halloween.theme.css' }, { file: 'braid.theme.css' }, null]) assert.equal(ThemeCompat.isSeasonalTheme(theme), false);
});
test('theme metadata marks a --bg-primary file as a palette', () => {
const meta = parseThemeMetadata(`/**
* @name Braid
*/
:root { --bg-primary: #0b0d12; --accent: #00ff9d; }`);
assert.equal(meta.palette, true);
});
test('theme metadata parser supports compact one-line comment blocks', () => {
const meta = parseThemeMetadata('/** @name Compact @description One line @version 1.0 @haven-theme-api 2 */');
assert.equal(meta.name, 'Compact');
assert.equal(meta.description, 'One line');
assert.equal(meta.version, '1.0');
assert.equal(meta.themeApi, 2);
assert.equal(meta.compatibility, 'unsupported');
assert.equal(meta.compatible, false);
});
test('compatibility cache keeps only the server verdict needed before paint', () => {
const storage = new MemoryStorage();
ThemeCompat.cacheThemes([
{ file: 'current.theme.css', themeApi: 1, themeApiDeclared: '1', compatibility: 'compatible', compatible: true },
{ file: 'future.theme.css', themeApi: 2, themeApiDeclared: '2', compatibility: 'unsupported', compatible: false },
], storage);
assert.equal(ThemeCompat.getCachedTheme('current.theme.css', storage).compatible, true);
assert.equal(ThemeCompat.getCachedTheme('future.theme.css', storage).compatible, false);
assert.equal(ThemeCompat.getCachedTheme('missing.theme.css', storage), null);
});
test('theme metadata files are read only from valid installed theme names', () => {
const themesDir = path.join(ROOT, 'themes');
assert.equal(readThemeMetadataFile(themesDir, '../package.json'), null);
assert.equal(readThemeMetadataFile(themesDir, 'missing.theme.css'), null);
assert.equal(readThemeMetadataFile(themesDir, 'braid.theme.css').compatible, true);
});
test('a theme metadata snapshot fails rather than returning a partial list', t => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'haven-themes-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
fs.writeFileSync(path.join(directory, 'valid.theme.css'), '/** @haven-theme-api 1 */');
fs.symlinkSync(path.join(directory, 'missing.css'), path.join(directory, 'broken.theme.css'));
assert.throws(() => readThemeMetadataSnapshot(directory));
});
test('theme request paths reject alternate and hidden stylesheet paths', () => {
assert.equal(themeFileFromRequestPath('/braid.theme.css'), 'braid.theme.css');
assert.equal(themeFileFromRequestPath('/my%20theme.theme.css'), 'my theme.theme.css');
assert.equal(themeFileFromRequestPath('//braid.theme.css'), null);
assert.equal(themeFileFromRequestPath('/nested/braid.theme.css'), null);
assert.equal(themeFileFromRequestPath('/%2Fbraid.theme.css'), null);
assert.equal(themeFileFromRequestPath('/.hidden.theme.css'), null);
assert.equal(themeFileFromRequestPath('/preview.png'), undefined);
});
test('theme stylesheet middleware blocks incompatible and alternate paths', async t => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'haven-theme-http-'));
fs.writeFileSync(path.join(directory, 'good.theme.css'), '/** @haven-theme-api 1 */\n:root {}');
fs.writeFileSync(path.join(directory, 'future.theme.css'), '/** @haven-theme-api 2 */\n:root {}');
fs.writeFileSync(path.join(directory, '.hidden.theme.css'), '/** @haven-theme-api 1 */\n:root {}');
fs.mkdirSync(path.join(directory, 'nested'));
fs.writeFileSync(path.join(directory, 'nested', 'good.theme.css'), '/** @haven-theme-api 1 */\n:root {}');
const app = express();
app.use('/themes', createThemeFileMiddleware(directory));
app.use('/themes', express.static(directory, { dotfiles: 'deny' }));
const server = await listen(app);
t.after(() => {
server.close();
fs.rmSync(directory, { recursive: true, force: true });
});
const base = `http://127.0.0.1:${server.address().port}`;
assert.equal((await fetch(`${base}/themes/good.theme.css`)).status, 200);
assert.equal((await fetch(`${base}/themes/future.theme.css`)).status, 409);
assert.equal((await fetch(`${base}/themes//good.theme.css`)).status, 404);
assert.equal((await fetch(`${base}/themes/%2Fgood.theme.css`)).status, 404);
assert.equal((await fetch(`${base}/themes/nested/good.theme.css`)).status, 404);
assert.equal((await fetch(`${base}/themes/.hidden.theme.css`)).status, 404);
assert.equal((await fetch(`${base}/themes/future.theme.css`, { method: 'HEAD' })).status, 409);
});
test('stored theme settings expose only compatible installed file themes', () => {
const themesDir = path.join(ROOT, 'themes');
const published = compatibleThemeFiles(themesDir, ['braid.theme.css', 'missing.theme.css']);
assert.deepEqual(published, ['braid.theme.css']);
assert.equal(validatedThemeDefault(themesDir, 'file:braid.theme.css', published), 'file:braid.theme.css');
assert.equal(validatedThemeDefault(themesDir, 'file:missing.theme.css', published), '');
});
test('a theme named without the file: prefix is normalised, not passed through', () => {
const themesDir = path.join(ROOT, 'themes');
const published = compatibleThemeFiles(themesDir, ['braid.theme.css']);
// A bare filename is the same intent as `file:`; it is what an admin writes by hand.
// Passing it through unchanged makes the client apply an unknown built-in theme, so
// the page loads no stylesheet and reports no error.
assert.equal(validatedThemeDefault(themesDir, 'braid.theme.css', published), 'file:braid.theme.css');
// A name that could only be a theme file, but is not one, must not reach the client.
assert.equal(validatedThemeDefault(themesDir, 'unpublished.theme.css', published), '');
// Built-in names are not files and stay exactly as they are.
assert.equal(validatedThemeDefault(themesDir, 'nord', published), 'nord');
assert.equal(validatedThemeDefault(themesDir, 'haven', published), 'haven');
// Nothing selected stays nothing selected.
assert.equal(validatedThemeDefault(themesDir, '', published), '');
assert.equal(validatedThemeDefault(themesDir, null, published), '');
});
test('safe mode persists for the tab and can be explicitly cleared', () => {
const session = new MemoryStorage();
assert.equal(ThemeCompat.isSafeMode({ search: '?haven-safe-mode=1' }, session), true);
assert.equal(ThemeCompat.isSafeMode({ search: '' }, session), true);
assert.equal(ThemeCompat.isSafeMode({ search: '?haven-safe-mode=0' }, session), false);
assert.equal(ThemeCompat.isSafeMode({ search: '' }, session), false);
});
test('both pages load compatibility helpers before the pre-paint theme script', () => {
for (const file of ['public/app.html', 'public/index.html']) {
const html = fs.readFileSync(path.join(ROOT, file), 'utf8');
const compatIndex = html.indexOf('/js/theme-compat.js');
const initIndex = html.indexOf('/js/theme-init.js');
assert.ok(compatIndex >= 0, `${file} does not load theme-compat.js`);
assert.ok(initIndex > compatIndex, `${file} loads theme-init.js before theme-compat.js`);
}
});
test('the app exposes recovery controls only inside the hidden safe-mode notice', () => {
const html = fs.readFileSync(path.join(ROOT, 'public/app.html'), 'utf8');
assert.match(html, /id="extension-safe-mode-notice"[^>]*style="[^"]*display:none/);
assert.match(html, /id="extension-reset-btn"/);
assert.match(html, /id="extension-safe-mode-exit-btn"/);
});
test('local recovery resets only extension choices and marks server sync pending', () => {
const local = new MemoryStorage({
haven_theme: 'file:test.theme.css',
haven_enabled_themes: '["test.theme.css"]',
haven_enabled_plugins: '["test.plugin.js"]',
haven_custom_hsv: '{"h":20}',
});
const session = new MemoryStorage();
ThemeCompat.resetLocalCustomizations(local, session);
assert.equal(local.getItem('haven_theme'), 'haven');
assert.equal(local.getItem('haven_enabled_themes'), '[]');
assert.equal(local.getItem('haven_enabled_plugins'), '[]');
assert.equal(local.getItem('haven_custom_hsv'), '{"h":20}');
assert.equal(ThemeCompat.isResetPending(session), true);
});
test('pre-paint loader injects only a theme with a cached compatible verdict', () => {
const compatible = runThemeInit({
cache: [{ file: 'test.theme.css', compatibility: 'compatible', compatible: true, themeApi: 1 }],
});
assert.equal(compatible.links.length, 1);
assert.equal(compatible.links[0].href, '/themes/test.theme.css');
const unknown = runThemeInit();
assert.equal(unknown.links.length, 0);
assert.equal(unknown.documentElement.attributes.get('data-haven-theme-pending'), 'test.theme.css');
});
test('pre-paint loader validates and injects an uncached compatible theme', async () => {
const result = runThemeInit({
themes: [{ file: 'test.theme.css', compatibility: 'compatible', compatible: true, themeApi: 1 }],
});
await new Promise(resolve => setImmediate(resolve));
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(result.fetches, ['/api/themes']);
assert.equal(result.links.length, 1);
assert.equal(result.links[0].href, '/themes/test.theme.css');
assert.equal(result.documentElement.attributes.has('data-haven-theme-pending'), false);
});
test('safe mode suppresses compatible theme CSS before paint', () => {
const result = runThemeInit({
search: '?haven-safe-mode=1',
cache: [{ file: 'test.theme.css', compatibility: 'compatible', compatible: true, themeApi: 1 }],
});
assert.equal(result.links.length, 0);
assert.equal(result.documentElement.attributes.get('data-theme'), 'haven');
assert.equal(result.documentElement.attributes.get('data-haven-safe-mode'), '1');
});
test('pre-paint loader restores the light or dark scheme theme.js last measured', () => {
const light = runThemeInit({ theme: 'daylight', stored: { haven_color_scheme: 'light' } });
assert.equal(light.documentElement.attributes.get('data-color-scheme'), 'light');
const file = runThemeInit({ stored: { haven_color_scheme: 'light' } });
assert.equal(file.documentElement.attributes.get('data-color-scheme'), 'light');
const junk = runThemeInit({ theme: 'daylight', stored: { haven_color_scheme: 'purple' } });
assert.equal(junk.documentElement.attributes.has('data-color-scheme'), false);
});
test('Haven, safe mode and a fresh account start from the dark default scheme', () => {
const haven = runThemeInit({ theme: 'haven', stored: { haven_color_scheme: 'light' } });
assert.equal(haven.documentElement.attributes.has('data-color-scheme'), false);
const safe = runThemeInit({ theme: 'daylight', search: '?haven-safe-mode=1', stored: { haven_color_scheme: 'light' } });
assert.equal(safe.documentElement.attributes.has('data-color-scheme'), false);
const fresh = runThemeInit({ theme: '', stored: { haven_color_scheme: 'light' } });
assert.equal(fresh.documentElement.attributes.has('data-color-scheme'), false);
});
test('safe mode never fetches or evaluates plugin files', async () => {
const result = await runPluginLoader({
safe: true,
enabledPlugins: ['example.plugin.js'],
plugins: [{ file: 'example.plugin.js', name: 'Example' }],
});
assert.deepEqual(result.fetches.sort(), ['/api/plugins', '/api/themes']);
const plugin = result.loader.loadedPlugins.get('example.plugin.js');
assert.equal(plugin.enabled, true);
assert.equal(plugin.instance, null);
assert.equal(plugin.suppressed, true);
});
test('safe mode fails closed when the compatibility helper is unavailable', async () => {
const result = await runPluginLoader({
withCompat: false,
search: '?haven-safe-mode=1',
enabledPlugins: ['example.plugin.js'],
plugins: [{ file: 'example.plugin.js', name: 'Example' }],
});
assert.deepEqual(result.fetches.sort(), ['/api/plugins', '/api/themes']);
assert.equal(result.loader.loadedPlugins.get('example.plugin.js').instance, null);
assert.equal(result.loader.loadedPlugins.get('example.plugin.js').suppressed, true);
});
test('pending recovery fails closed when the compatibility helper is unavailable', async () => {
const result = await runPluginLoader({
withCompat: false,
recoveryPending: true,
enabledPlugins: ['example.plugin.js'],
plugins: [{ file: 'example.plugin.js', name: 'Example' }],
});
assert.deepEqual(result.fetches.sort(), ['/api/plugins', '/api/themes']);
assert.equal(result.loader.loadedPlugins.get('example.plugin.js').instance, null);
assert.equal(result.loader.loadedPlugins.get('example.plugin.js').suppressed, true);
});
test('an incompatible selected theme falls back to Haven without injecting CSS', async () => {
const result = await runPluginLoader({
theme: 'file:future.theme.css',
themes: [{
file: 'future.theme.css',
published: true,
compatibility: 'unsupported',
compatible: false,
themeApi: 2,
themeApiDeclared: '2',
}],
});
assert.equal(result.links.length, 0);
assert.equal(result.localStorage.getItem('haven_theme'), 'haven');
assert.equal(result.documentElement.attributes.get('data-theme'), 'haven');
});
test('a transient theme API failure preserves the selected theme preference', async () => {
const result = await runPluginLoader({
theme: 'file:saved.theme.css',
themeFetchError: true,
});
assert.equal(result.links.length, 0);
assert.equal(result.localStorage.getItem('haven_theme'), 'file:saved.theme.css');
assert.equal(result.documentElement.attributes.get('data-theme'), undefined);
});
test('a stale local theme fallback does not schedule a server preference write', async () => {
const socketEvents = [];
const socket = {
connected: false,
once(event) { socketEvents.push(`once:${event}`); },
off() {},
emit(event) { socketEvents.push(`emit:${event}`); },
};
const result = await runPluginLoader({ theme: 'file:removed.theme.css', socket, themes: [] });
assert.equal(result.localStorage.getItem('haven_theme'), 'haven');
assert.deepEqual(socketEvents, []);
});
test('refresh reconciles a removed theme before applying fallback', async () => {
const installed = [{
file: 'temporary.theme.css',
compatibility: 'compatible',
compatible: true,
themeApi: 1,
}];
const result = await runPluginLoader({
theme: 'file:temporary.theme.css',
themeResponses: [installed, []],
});
await result.loader.refresh();
assert.equal(result.loader.loadedThemes.has('temporary.theme.css'), false);
assert.equal(result.localStorage.getItem('haven_theme'), 'haven');
});
test('unpublished palette themes do not stack on a built-in theme', async () => {
const result = await runPluginLoader({
theme: 'matrix',
enabledThemes: ['braid.theme.css', 'compact.theme.css'],
themes: [
{
file: 'braid.theme.css',
published: false,
palette: true,
compatible: true,
compatibility: 'legacy',
},
{
file: 'compact.theme.css',
published: false,
palette: true,
compatible: true,
compatibility: 'compatible',
themeApi: 1,
},
],
});
assert.equal(result.links.length, 0);
result.loader.reapplyEnabledThemes();
assert.equal(result.links.length, 0);
assert.equal(result.localStorage.getItem('haven_enabled_themes'), '[]');
assert.equal(result.loader.loadedThemes.get('braid.theme.css').enabled, false);
});
test('unpublished tweak themes still stack on a built-in theme', async () => {
const result = await runPluginLoader({
theme: 'matrix',
enabledThemes: ['fonts.theme.css'],
themes: [{
file: 'fonts.theme.css',
published: false,
palette: false,
compatible: true,
compatibility: 'legacy',
}],
});
assert.equal(result.links.length, 1);
assert.equal(result.links[0].id, 'haven-theme-fonts.theme.css');
});
test('turning a palette theme on in Settings saves it as the account theme', async () => {
const emitted = [];
const socket = {
connected: true,
on() {},
off() {},
emit(event, payload) { emitted.push(JSON.stringify([event, payload])); },
};
const result = await runPluginLoader({
theme: 'matrix',
socket,
viewer: 'admin',
themes: [{
file: 'neon.theme.css',
published: false,
palette: true,
compatible: true,
compatibility: 'compatible',
themeApi: 1,
}],
});
result.loader.enableTheme('neon.theme.css');
assert.equal(result.localStorage.getItem('haven_theme'), 'file:neon.theme.css');
assert.deepEqual(emitted, [JSON.stringify(['set-preference', { key: 'theme', value: 'file:neon.theme.css' }])]);
});
test('a member cannot turn on or pick an unpublished theme file', async () => {
for (const viewer of ['member', null]) {
const emitted = [];
const socket = { connected: true, on() {}, off() {}, emit(event, payload) { emitted.push([event, payload]); } };
const result = await runPluginLoader({
theme: 'matrix',
socket,
viewer,
themes: [
{ file: 'neon.theme.css', published: false, palette: true, compatible: true, compatibility: 'compatible', themeApi: 1 },
{ file: 'fonts.theme.css', published: false, palette: false, compatible: true, compatibility: 'legacy' },
],
});
result.loader.enableTheme('neon.theme.css');
result.loader.enableTheme('fonts.theme.css');
assert.equal(result.loader.selectFileTheme('neon.theme.css'), false, `${viewer}: palette`);
assert.equal(result.loader.selectFileTheme('fonts.theme.css'), false, `${viewer}: tweak`);
assert.equal(result.localStorage.getItem('haven_theme'), 'matrix');
assert.equal(result.loader.loadedThemes.get('fonts.theme.css').enabled, false);
assert.equal(result.links.length, 0);
assert.deepEqual(emitted, []);
}
});
test('a member keeps an unpublished tweak they already had on, and can turn it off', async () => {
const result = await runPluginLoader({
theme: 'matrix',
viewer: 'member',
enabledThemes: ['fonts.theme.css'],
themes: [{ file: 'fonts.theme.css', published: false, palette: false, compatible: true, compatibility: 'legacy' }],
});
assert.equal(result.loader.loadedThemes.get('fonts.theme.css').enabled, true);
assert.equal(result.loader.selectFileTheme('fonts.theme.css'), true);
assert.equal(result.loader.loadedThemes.get('fonts.theme.css').enabled, false);
assert.equal(result.loader.selectFileTheme('fonts.theme.css'), false, 'not back on');
});
test('a theme the server refused falls back to Haven and saves that instead', async () => {
const emitted = [];
const socket = { connected: true, on() {}, off() {}, emit(event, payload) { emitted.push([event, payload?.value]); } };
const result = await runPluginLoader({
theme: 'file:neon.theme.css',
socket,
viewer: 'member',
themes: [{ file: 'neon.theme.css', published: true, palette: true, compatible: true, compatibility: 'compatible', themeApi: 1 }],
});
assert.equal(result.links.length, 1);
result.loader.onThemeRefused('file:other.theme.css');
assert.equal(result.localStorage.getItem('haven_theme'), 'file:neon.theme.css', 'a refusal for another theme changes nothing');
result.loader.onThemeRefused('file:neon.theme.css');
assert.equal(result.localStorage.getItem('haven_theme'), 'haven');
assert.equal(result.documentElement.attributes.get('data-theme'), 'haven');
assert.equal(result.loader.loadedThemes.get('neon.theme.css').enabled, false);
assert.deepEqual(emitted.filter(([event]) => event === 'set-preference'), [['set-preference', 'haven']]);
});
test('picking a published theme in the theme picker selects it and saves it to the account', async () => {
const emitted = [];
const socket = {
connected: true,
on() {},
off() {},
emit(event, payload) { emitted.push(JSON.stringify([event, payload])); },
};
const result = await runPluginLoader({
theme: 'matrix',
socket,
themes: [{ file: 'neon.theme.css', published: true, palette: true, compatible: true, compatibility: 'compatible', themeApi: 1 }],
});
let redraws = 0;
result.document.addEventListener('haven:themes-changed', () => { redraws++; });
assert.equal(result.loader.selectFileTheme('neon.theme.css'), true);
assert.equal(result.localStorage.getItem('haven_theme'), 'file:neon.theme.css');
assert.deepEqual(emitted, [JSON.stringify(['set-preference', { key: 'theme', value: 'file:neon.theme.css' }])]);
assert.ok(redraws > 0, 'the picker is told to redraw');
// Picking it again is not another save.
result.loader.selectFileTheme('neon.theme.css');
assert.equal(emitted.length, 1);
});
test('picking a theme that only adds CSS switches it on and off without changing the theme', async () => {
const result = await runPluginLoader({
theme: 'matrix',
viewer: 'admin',
themes: [{ file: 'fonts.theme.css', published: false, palette: false, compatible: true, compatibility: 'legacy' }],
});
result.loader.selectFileTheme('fonts.theme.css');
assert.equal(result.loader.loadedThemes.get('fonts.theme.css').enabled, true);
assert.equal(result.localStorage.getItem('haven_theme'), 'matrix');
result.loader.selectFileTheme('fonts.theme.css');
assert.equal(result.loader.loadedThemes.get('fonts.theme.css').enabled, false);
assert.equal(result.localStorage.getItem('haven_enabled_themes'), '[]');
});
test('safe mode refuses a theme picked in the theme picker', async () => {
const result = await runPluginLoader({
safe: true,
themes: [{ file: 'neon.theme.css', published: true, palette: true, compatible: true, compatibility: 'compatible', themeApi: 1 }],
});
assert.equal(result.loader.selectFileTheme('neon.theme.css'), false);
assert.equal(result.loader.suppressed, true);
assert.equal(result.links.length, 0);
});
test('a palette theme left in the enabled list is shown off while a built-in theme is active', async () => {
const result = await runPluginLoader({
theme: 'matrix',
enabledThemes: ['neon.theme.css'],
themes: [{
file: 'neon.theme.css',
published: false,
palette: true,
compatible: true,
compatibility: 'compatible',
themeApi: 1,
}],
});
assert.equal(result.links.length, 0);
assert.equal(result.loader.loadedThemes.get('neon.theme.css').enabled, false);
});
test('admin theme settings accept only installed themes and clear an unpublished default', t => {
const { db, handlers, outgoing } = createAdminThemeHarness();
t.after(() => db.close());
const update = handlers.get('update-server-setting');
update({ key: 'published_themes', value: '["missing.theme.css"]' });
assert.match(outgoing.at(-1).payload, /installed, compatible/);
assert.equal(db.prepare("SELECT value FROM server_settings WHERE key = 'published_themes'").get(), undefined);
update({ key: 'published_themes', value: '["braid.theme.css"]' });
update({ key: 'default_theme', value: 'file:braid.theme.css' });
assert.equal(
db.prepare("SELECT value FROM server_settings WHERE key = 'default_theme'").get().value,
'file:braid.theme.css'
);
update({ key: 'published_themes', value: '[]' });
assert.equal(
db.prepare("SELECT value FROM server_settings WHERE key = 'default_theme'").get().value,
''
);
});
test('existing invalid theme settings are hidden without rewriting stored data', t => {
const { db, handlers, outgoing } = createAdminThemeHarness();
t.after(() => db.close());
db.prepare('INSERT INTO server_settings (key, value) VALUES (?, ?)')
.run('published_themes', '["braid.theme.css","missing.theme.css"]');
db.prepare('INSERT INTO server_settings (key, value) VALUES (?, ?)')
.run('default_theme', 'file:missing.theme.css');
handlers.get('get-server-settings')();
const response = outgoing.find(item => item.event === 'server-settings').payload;
assert.equal(response.published_themes, '["braid.theme.css"]');
assert.equal(response.default_theme, '');
assert.equal(
db.prepare("SELECT value FROM server_settings WHERE key = 'default_theme'").get().value,
'file:missing.theme.css'
);
});