mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-26 16:45:16 +00:00
936 lines
36 KiB
JavaScript
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'
|
|
);
|
|
});
|