'use strict'; // theme.js runs in a small fake page here: enough DOM for the theme picker // and the Custom editor, so the tests can see which colors end up on :root. const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); const vm = require('node:vm'); const ROOT = path.join(__dirname, '..'); const THEME_SOURCE = fs.readFileSync(path.join(ROOT, 'public/js/theme.js'), 'utf8'); const ThemeCompat = require('../public/js/theme-compat'); const { readCoreCss } = require('./coreCss'); function memoryStorage(initial = {}) { const values = new Map(Object.entries(initial)); return { getItem: key => (values.has(key) ? values.get(key) : null), setItem: (key, value) => { values.set(key, String(value)); }, removeItem: key => { values.delete(key); }, has: key => values.has(key), }; } function inlineStyle() { const props = new Map(); return { props, setProperty: (key, value) => { props.set(key, String(value)); }, removeProperty: key => { props.delete(key); }, getPropertyValue: key => props.get(key) || '', }; } function element(id) { const attributes = new Map(); return { id, style: inlineStyle(), dataset: {}, classList: { add() {}, remove() {}, toggle() {}, contains: () => false }, setAttribute: (name, value) => { attributes.set(name, String(value)); }, getAttribute: name => (attributes.has(name) ? attributes.get(name) : null), removeAttribute: name => { attributes.delete(name); }, hasAttribute: name => attributes.has(name), addEventListener() {}, querySelectorAll: () => [], querySelector: () => null, appendChild: child => child, }; } // A 2D context that accepts every drawing call and draws nothing. function canvasContext() { return new Proxy({}, { get(target, key) { if (key in target) return target[key]; if (key === 'createImageData') return () => ({ data: [] }); return () => {}; }, }); } function canvas(id) { return Object.assign(element(id), { width: 12, height: 12, getContext: () => canvasContext(), getBoundingClientRect: () => ({ left: 0, top: 0, width: 12, height: 12 }), }); } function loadThemePage(stored = {}, { computedColor = '' } = {}) { const page = { computedColor }; const localStorage = memoryStorage(stored); const sessionStorage = memoryStorage(); const root = element('html'); const nodes = new Map([ ['theme-selector', element('theme-selector')], ['custom-theme-editor', element('custom-theme-editor')], ['custom-hue-bar', canvas('custom-hue-bar')], ['custom-triangle', canvas('custom-triangle')], ['custom-theme-swatch', element('custom-theme-swatch')], ]); const timers = []; const document = { documentElement: root, body: element('body'), head: element('head'), hidden: false, getElementById: id => nodes.get(id) || null, querySelectorAll: () => [], querySelector: () => null, createElement: tag => element(tag), addEventListener() {}, }; const window = { location: { href: 'http://localhost/app', search: '' }, HavenThemeCompat: ThemeCompat, addEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {} }), }; const context = { window, document, localStorage, sessionStorage, console, setTimeout: fn => { timers.push(fn); return timers.length; }, clearTimeout() {}, requestAnimationFrame: () => 1, cancelAnimationFrame() {}, getComputedStyle: () => ({ getPropertyValue: () => '', color: page.computedColor }), URLSearchParams, CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init?.detail; } }, }; window.document = document; window.localStorage = localStorage; vm.createContext(context); vm.runInContext(THEME_SOURCE, context, { filename: 'theme.js' }); const runTimers = () => { while (timers.length) timers.shift()(); }; Object.assign(page, { context, root, nodes, localStorage, runTimers }); return page; } test('a fresh account keeps Haven colors instead of the Custom palette', () => { const page = loadThemePage(); page.context.initThemeSwitcher('theme-selector'); page.runTimers(); assert.equal(page.root.style.props.get('--bg-primary'), undefined, 'no Custom color on :root'); assert.equal(page.root.style.props.get('--accent'), undefined, 'no Custom accent on :root'); assert.equal(page.localStorage.has('haven_custom_hsv'), false, 'loading the page does not save a Custom color'); assert.ok(page.nodes.get('custom-theme-swatch').style.background, 'the Custom button still shows its color'); }); test('a built-in theme is not covered by a Custom color saved earlier', () => { const page = loadThemePage({ haven_theme: 'discord', haven_custom_hsv: JSON.stringify({ h: 120, s: 0.8, v: 0.9 }), }); page.context.initThemeSwitcher('theme-selector'); page.runTimers(); assert.equal(page.root.style.props.get('--bg-primary'), undefined); }); test('an account that chose Custom keeps its Custom palette', () => { const hsv = { h: 120, s: 0.8, v: 0.9 }; const page = loadThemePage({ haven_theme: 'custom', haven_custom_hsv: JSON.stringify(hsv) }); page.context.initThemeSwitcher('theme-selector'); page.runTimers(); const expected = page.context.generateCustomPalette(hsv.h, hsv.s, hsv.v, hsv.s, false); assert.equal(page.root.style.props.get('--bg-primary'), expected['--bg-primary']); assert.equal(page.root.style.props.get('--accent'), expected['--accent']); }); test('Custom chosen from the server applies even before a color was ever saved', () => { const page = loadThemePage(); page.context.initThemeSwitcher('theme-selector'); page.runTimers(); page.context.applyThemeFromServer('custom', true, true); assert.ok(page.root.style.props.get('--bg-primary'), 'the default Custom palette is applied'); assert.equal(page.localStorage.getItem('haven_theme'), 'custom'); }); test('every theme gets light or dark native controls from its own background', () => { const light = new Set(['gospel', 'win95', 'daylight', 'cloudy', 'amni-scient-light.theme.css', 'braid-light.theme.css']); const themes = new Map(); const core = readCoreCss(); for (const [, name, color] of core.matchAll(/\[data-theme="([a-z0-9]+)"\]\s*\{[^}]*?--bg-primary:\s*([^;]+);/g)) themes.set(name, color); const haven = core.match(/:root,\s*\[data-theme="haven"\]\s*\{[^}]*?--bg-primary:\s*([^;]+);/); themes.set('haven', haven[1]); const themeDir = path.join(ROOT, 'themes'); for (const file of fs.readdirSync(themeDir).filter(f => f.endsWith('.theme.css'))) { const css = fs.readFileSync(path.join(themeDir, file), 'utf8'); themes.set(file, css.match(/--bg-primary:\s*([^;]+);/)[1]); // A theme that names its scheme must agree with what its colors say. const declared = css.match(/color-scheme:\s*(light|dark)\s*;/); if (declared) assert.equal(ThemeCompat.colorSchemeFor(themes.get(file)), declared[1], file); } assert.ok(themes.size > 30, 'built-in and file themes were all found'); for (const [name, color] of themes) { assert.equal(ThemeCompat.colorSchemeFor(color), light.has(name) ? 'light' : 'dark', `${name} (${color})`); } }); test('the color scheme reader understands the color syntaxes a theme can use', () => { assert.equal(ThemeCompat.colorSchemeFor('#fff'), 'light'); assert.equal(ThemeCompat.colorSchemeFor('#000000'), 'dark'); assert.equal(ThemeCompat.colorSchemeFor('#F3F2EF'), 'light'); assert.equal(ThemeCompat.colorSchemeFor('rgb(250, 248, 245)'), 'light'); assert.equal(ThemeCompat.colorSchemeFor('rgb(25 27 40)'), 'dark'); assert.equal(ThemeCompat.colorSchemeFor('rgba(245, 247, 250, 0.95)'), 'light'); assert.equal(ThemeCompat.colorSchemeFor('color(srgb 0.98 0.97 0.96)'), 'light'); assert.equal(ThemeCompat.colorSchemeFor('rgba(0, 0, 0, 0)'), null, 'transparent says nothing'); assert.equal(ThemeCompat.colorSchemeFor('oklch(0.9 0.1 200)'), null); assert.equal(ThemeCompat.colorSchemeFor(''), null); assert.equal(ThemeCompat.colorSchemeFor('#12'), null); }); test('Custom and RGB set the scheme from their light or dark base', () => { const page = loadThemePage(); for (const hue of [0, 60, 120, 200, 260, 320]) { page.context.applyCustomVars(page.context.generateCustomPalette(hue, 0.75, 0.95, 0.75, true)); assert.equal(page.root.getAttribute('data-color-scheme'), 'light', `light base, hue ${hue}`); page.context.applyCustomVars(page.context.generateCustomPalette(hue, 0.75, 0.95, 0.75, false)); assert.equal(page.root.getAttribute('data-color-scheme'), 'dark', `dark base, hue ${hue}`); } assert.equal(page.localStorage.getItem('haven_color_scheme'), 'dark', 'stored for the next first paint'); }); test('switching to a built-in theme measures its background again', () => { const page = loadThemePage({}, { computedColor: 'rgb(25, 27, 40)' }); page.runTimers(); assert.equal(page.root.getAttribute('data-color-scheme'), 'dark'); page.computedColor = 'rgb(250, 248, 245)'; page.context.applyThemeFromServer('daylight', true, true); page.runTimers(); assert.equal(page.root.getAttribute('data-color-scheme'), 'light'); assert.equal(page.localStorage.getItem('haven_color_scheme'), 'light'); }); test('every native select and its options use the theme input colors', () => { const core = readCoreCss(); assert.match(core, /:where\(:root\)\s*\{\s*color-scheme:\s*dark;\s*\}/); assert.match(core, /:where\(:root\[data-color-scheme="light"\]\)\s*\{\s*color-scheme:\s*light;\s*\}/); const select = core.match(/:where\(select\)\s*\{([^}]*)\}/); assert.ok(select, 'a base rule for every select'); assert.match(select[1], /background-color:\s*var\(--bg-input\)/); assert.match(select[1], /color:\s*var\(--text-primary\)/); assert.match(select[1], /border:\s*1px solid var\(--border\)/); assert.match(core, /:where\(select:focus\)\s*\{\s*border-color:\s*var\(--accent\);\s*\}/); const option = core.match(/:where\(select option, select optgroup\)\s*\{([^}]*)\}/); assert.ok(option, 'a base rule for every option'); assert.match(option[1], /background-color:\s*var\(--bg-input\)/); assert.match(option[1], /color:\s*var\(--text-primary\)/); });