From 511e6d377c0e8a907374bbb9046786e2aff568ca Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sun, 12 Oct 2025 14:35:53 +0200 Subject: [PATCH] Add comprehensive theme and background front-end tests (#302) --- .../assets/js/app/__tests__/config.test.js | 14 + .../assets/js/app/__tests__/document-stub.js | 46 +++ .../js/app/__tests__/dom-environment.js | 292 ++++++++++++++++++ .../js/app/__tests__/theme-background.test.js | 216 +++++++++++++ web/public/assets/js/background.js | 25 +- web/public/assets/js/theme.js | 90 +++++- 6 files changed, 661 insertions(+), 22 deletions(-) create mode 100644 web/public/assets/js/app/__tests__/dom-environment.js create mode 100644 web/public/assets/js/app/__tests__/theme-background.test.js diff --git a/web/public/assets/js/app/__tests__/config.test.js b/web/public/assets/js/app/__tests__/config.test.js index 6da3935..527f297 100644 --- a/web/public/assets/js/app/__tests__/config.test.js +++ b/web/public/assets/js/app/__tests__/config.test.js @@ -55,6 +55,15 @@ test('readAppConfig returns an empty object and logs on parse failure', () => { console.error = originalError; }); +test('readAppConfig ignores non-object JSON payloads', () => { + resetDocumentStub(); + documentStub.setConfigElement({ + getAttribute: name => (name === 'data-app-config' ? '42' : null) + }); + + assert.deepEqual(readAppConfig(), {}); +}); + test('mergeConfig applies default values when fields are missing', () => { const result = mergeConfig({}); assert.deepEqual(result, { @@ -97,3 +106,8 @@ test('mergeConfig falls back to defaults for invalid numeric values', () => { assert.equal(result.refreshMs, DEFAULT_CONFIG.refreshMs); assert.equal(result.maxNodeDistanceKm, DEFAULT_CONFIG.maxNodeDistanceKm); }); + +test('document stub returns null for unrelated selectors', () => { + resetDocumentStub(); + assert.equal(documentStub.querySelector('#missing'), null); +}); diff --git a/web/public/assets/js/app/__tests__/document-stub.js b/web/public/assets/js/app/__tests__/document-stub.js index e8838db..3ef6f54 100644 --- a/web/public/assets/js/app/__tests__/document-stub.js +++ b/web/public/assets/js/app/__tests__/document-stub.js @@ -12,20 +12,46 @@ * limitations under the License. */ +/** + * Minimal document implementation that exposes the subset of behaviour needed + * by the front-end modules during unit tests. + */ class DocumentStub { + /** + * Instantiate a new stub with a clean internal state. + */ constructor() { this.reset(); } + /** + * Clear tracked configuration elements and registered event listeners. + * + * @returns {void} + */ reset() { this.configElement = null; this.listeners = new Map(); } + /** + * Provide an element that will be returned by ``querySelector`` when the + * configuration selector is requested. + * + * @param {?Element} element DOM node exposing ``getAttribute``. + * @returns {void} + */ setConfigElement(element) { this.configElement = element; } + /** + * Return the registered configuration element when the matching selector is + * provided. + * + * @param {string} selector CSS selector requested by the module under test. + * @returns {?Element} Config element or ``null`` when unavailable. + */ querySelector(selector) { if (selector === '[data-app-config]') { return this.configElement; @@ -33,10 +59,24 @@ class DocumentStub { return null; } + /** + * Register an event handler, mirroring the DOM ``addEventListener`` API. + * + * @param {string} event Event identifier. + * @param {Function} handler Callback invoked when ``dispatchEvent`` is + * called. + * @returns {void} + */ addEventListener(event, handler) { this.listeners.set(event, handler); } + /** + * Trigger a previously registered listener. + * + * @param {string} event Event identifier used when registering the handler. + * @returns {void} + */ dispatchEvent(event) { const handler = this.listeners.get(event); if (handler) { @@ -46,6 +86,12 @@ class DocumentStub { } export const documentStub = new DocumentStub(); + +/** + * Reset the shared stub between test cases to avoid state bleed. + * + * @returns {void} + */ export function resetDocumentStub() { documentStub.reset(); } diff --git a/web/public/assets/js/app/__tests__/dom-environment.js b/web/public/assets/js/app/__tests__/dom-environment.js new file mode 100644 index 0000000..c8aa009 --- /dev/null +++ b/web/public/assets/js/app/__tests__/dom-environment.js @@ -0,0 +1,292 @@ +/* + * Copyright (C) 2025 l5yth + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Simple class list implementation supporting the subset of DOMTokenList + * behaviour required by the tests. + */ +class MockClassList { + constructor() { + this._values = new Set(); + } + + /** + * Add one or more CSS classes to the element. + * + * @param {...string} names Class names to insert into the list. + * @returns {void} + */ + add(...names) { + names.forEach(name => { + if (name) { + this._values.add(name); + } + }); + } + + /** + * Remove one or more CSS classes from the element. + * + * @param {...string} names Class names to delete from the list. + * @returns {void} + */ + remove(...names) { + names.forEach(name => { + if (name) { + this._values.delete(name); + } + }); + } + + /** + * Determine whether the class list currently contains ``name``. + * + * @param {string} name Target class name. + * @returns {boolean} ``true`` when the class is present. + */ + contains(name) { + return this._values.has(name); + } + + /** + * Toggle the provided class name. + * + * @param {string} name Class name to toggle. + * @param {boolean} [force] Optional forced state mirroring ``DOMTokenList``. + * @returns {boolean} ``true`` when the class is present after toggling. + */ + toggle(name, force) { + if (force === true) { + this._values.add(name); + return true; + } + if (force === false) { + this._values.delete(name); + return false; + } + if (this._values.has(name)) { + this._values.delete(name); + return false; + } + this._values.add(name); + return true; + } +} + +/** + * Minimal DOM element implementation exposing the subset of behaviour exercised + * by the frontend entrypoints. + */ +class MockElement { + /** + * @param {string} tagName Element name used for diagnostics. + * @param {Map} registry Storage shared with the + * containing document to support ``getElementById``. + */ + constructor(tagName, registry) { + this.tagName = tagName.toUpperCase(); + this._registry = registry; + this.attributes = new Map(); + this.dataset = {}; + this.style = {}; + this.textContent = ''; + this.classList = new MockClassList(); + } + + /** + * Associate an attribute with the element. + * + * @param {string} name Attribute identifier. + * @param {string} value Attribute value. + * @returns {void} + */ + setAttribute(name, value) { + this.attributes.set(name, String(value)); + if (name === 'id' && this._registry) { + this._registry.set(String(value), this); + } + } + + /** + * Retrieve an attribute value. + * + * @param {string} name Attribute identifier. + * @returns {?string} Matching attribute or ``null`` when absent. + */ + getAttribute(name) { + return this.attributes.has(name) ? this.attributes.get(name) : null; + } +} + +/** + * Create a deterministic DOM environment that provides just enough behaviour + * for the UI scripts to execute inside Node.js unit tests. + * + * @param {{ + * readyState?: 'loading' | 'interactive' | 'complete', + * cookie?: string, + * includeBody?: boolean, + * bodyHasDarkClass?: boolean + * }} [options] + * @returns {{ + * window: Window & { dispatchEvent: Function }, + * document: Document, + * createElement: (tagName?: string, id?: string) => MockElement, + * registerElement: (id: string, element: MockElement) => void, + * setComputedStyleImplementation: (impl: Function) => void, + * triggerDOMContentLoaded: () => void, + * dispatchWindowEvent: (event: string) => void, + * getCookieString: () => string, + * setCookieString: (value: string) => void, + * cleanup: () => void + * }} + */ +export function createDomEnvironment(options = {}) { + const { + readyState = 'complete', + cookie = '', + includeBody = true, + bodyHasDarkClass = true + } = options; + + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + + const registry = new Map(); + const documentListeners = new Map(); + const windowListeners = new Map(); + let computedStyleImpl = null; + let cookieStore = cookie; + + const document = { + readyState, + documentElement: new MockElement('html', registry), + body: includeBody ? new MockElement('body', registry) : null, + addEventListener(event, handler) { + documentListeners.set(event, handler); + }, + removeEventListener(event) { + documentListeners.delete(event); + }, + dispatchEvent(event) { + const handler = documentListeners.get(event); + if (handler) handler(); + }, + getElementById(id) { + return registry.get(id) || null; + }, + querySelector() { + return null; + }, + createElement(tagName) { + return new MockElement(tagName, registry); + } + }; + + if (document.body && bodyHasDarkClass) { + document.body.classList.add('dark'); + } + + Object.defineProperty(document, 'cookie', { + get() { + return cookieStore; + }, + set(value) { + cookieStore = cookieStore ? `${cookieStore}; ${value}` : value; + } + }); + + const window = { + document, + addEventListener(event, handler) { + windowListeners.set(event, handler); + }, + removeEventListener(event) { + windowListeners.delete(event); + }, + dispatchEvent(event) { + const handler = windowListeners.get(event); + if (handler) handler(); + }, + getComputedStyle(target) { + if (typeof computedStyleImpl === 'function') { + return computedStyleImpl(target); + } + return { + getPropertyValue() { + return ''; + } + }; + } + }; + + globalThis.window = window; + globalThis.document = document; + + /** + * Create and optionally register a mock element. + * + * @param {string} [tagName='div'] Tag name of the element. + * @param {string} [id] Optional identifier registered with the document. + * @returns {MockElement} New mock element instance. + */ + function createElement(tagName = 'div', id) { + const element = new MockElement(tagName, registry); + if (id) { + element.setAttribute('id', id); + } + return element; + } + + /** + * Register an element instance so that ``getElementById`` can resolve it. + * + * @param {string} id Element identifier. + * @param {MockElement} element Element instance to register. + * @returns {void} + */ + function registerElement(id, element) { + registry.set(id, element); + } + + return { + window, + document, + createElement, + registerElement, + setComputedStyleImplementation(impl) { + computedStyleImpl = impl; + }, + triggerDOMContentLoaded() { + const handler = documentListeners.get('DOMContentLoaded'); + if (handler) handler(); + }, + dispatchWindowEvent(event) { + const handler = windowListeners.get(event); + if (handler) handler(); + }, + getCookieString() { + return cookieStore; + }, + setCookieString(value) { + cookieStore = value; + }, + cleanup() { + globalThis.window = originalWindow; + globalThis.document = originalDocument; + } + }; +} diff --git a/web/public/assets/js/app/__tests__/theme-background.test.js b/web/public/assets/js/app/__tests__/theme-background.test.js new file mode 100644 index 0000000..1684bac --- /dev/null +++ b/web/public/assets/js/app/__tests__/theme-background.test.js @@ -0,0 +1,216 @@ +/* + * Copyright (C) 2025 l5yth + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import vm from 'node:vm'; + +import { createDomEnvironment } from './dom-environment.js'; + +const themeModuleUrl = new URL('../../theme.js', import.meta.url); +const backgroundModuleUrl = new URL('../../background.js', import.meta.url); +const themeSource = await readFile(themeModuleUrl, 'utf8'); +const backgroundSource = await readFile(backgroundModuleUrl, 'utf8'); + +/** + * Evaluate a browser-oriented script within the provided DOM environment. + * + * @param {string} source Module source code to execute. + * @param {URL} url Identifier for the executed script. + * @param {ReturnType} env Active DOM harness. + * @returns {void} + */ +function executeInDom(source, url, env) { + const context = vm.createContext({ + console, + setTimeout, + clearTimeout, + setInterval, + clearInterval + }); + context.window = env.window; + context.document = env.document; + context.global = context; + context.globalThis = context; + context.window.window = context.window; + context.window.document = context.document; + context.window.globalThis = context; + context.window.console = console; + + vm.runInContext(source, context, { filename: url.pathname, displayErrors: true }); +} + +test('theme and background modules behave correctly across scenarios', async t => { + const env = createDomEnvironment({ readyState: 'complete', cookie: '' }); + try { + const toggle = env.createElement('button', 'themeToggle'); + env.registerElement('themeToggle', toggle); + let filterInvocations = 0; + env.window.applyFiltersToAllTiles = () => { + filterInvocations += 1; + }; + + executeInDom(themeSource, themeModuleUrl, env); + executeInDom(backgroundSource, backgroundModuleUrl, env); + + const themeHelpers = env.window.__themeCookie; + const themeHooks = themeHelpers.__testHooks; + const backgroundHelpers = env.window.__potatoBackground; + const backgroundHooks = backgroundHelpers.__testHooks; + + await t.test('initialises with a dark theme and persists cookies', () => { + assert.equal(env.document.documentElement.getAttribute('data-theme'), 'dark'); + assert.equal(env.document.body.classList.contains('dark'), true); + assert.equal(toggle.textContent, '☀️'); + themeHelpers.persistTheme('light'); + themeHelpers.setCookie('bare', '1'); + themeHooks.exerciseSetCookieGuard(); + themeHelpers.setCookie('flag', 'true', { Secure: true }); + const cookieString = env.getCookieString(); + assert.equal(themeHelpers.getCookie('flag'), 'true'); + assert.equal(themeHelpers.getCookie('missing'), null); + assert.match(cookieString, /theme=light/); + assert.match(cookieString, /; path=\//); + assert.match(cookieString, /; SameSite=Lax/); + assert.match(cookieString, /; Secure/); + }); + + await t.test('serializeCookieOptions covers boolean and string attributes', () => { + const withAttributes = themeHooks.serializeCookieOptions({ Secure: true, HttpOnly: '1' }); + assert.equal(withAttributes.includes('; Secure'), true); + assert.equal(withAttributes.includes('; HttpOnly=1'), true); + const secureOnly = themeHooks.serializeCookieOptions({ Secure: true }); + assert.equal(secureOnly.trim(), '; Secure'); + assert.equal(themeHooks.formatCookieOption(['HttpOnly', '1']), '; HttpOnly=1'); + assert.equal(themeHooks.formatCookieOption(['Secure', true]), '; Secure'); + assert.equal(themeHooks.serializeCookieOptions({}), ''); + assert.equal(themeHooks.serializeCookieOptions(), ''); + }); + + await t.test('re-bootstrap handles DOMContentLoaded flow and filter hooks', () => { + env.document.readyState = 'loading'; + filterInvocations = 0; + env.setCookieString('theme=light'); + themeHooks.bootstrap(); + env.triggerDOMContentLoaded(); + assert.equal(env.document.documentElement.getAttribute('data-theme'), 'light'); + assert.equal(env.document.body.classList.contains('dark'), false); + assert.equal(toggle.textContent, '🌙'); + assert.equal(filterInvocations, 1); + env.document.removeEventListener('DOMContentLoaded', themeHooks.handleReady); + }); + + await t.test('handleReady tolerates missing toggle button', () => { + env.registerElement('themeToggle', null); + themeHooks.handleReady(); + env.registerElement('themeToggle', toggle); + }); + + await t.test('applyTheme copes with absent DOM nodes', () => { + const originalBody = env.document.body; + const originalRoot = env.document.documentElement; + env.document.body = null; + env.document.documentElement = null; + assert.equal(themeHooks.applyTheme('dark'), true); + env.document.body = originalBody; + env.document.documentElement = originalRoot; + assert.equal(themeHooks.applyTheme('light'), false); + }); + + await t.test('background bootstrap waits for DOM readiness', () => { + env.setComputedStyleImplementation(() => ({ getPropertyValue: () => ' rgb(15, 15, 15) ' })); + env.document.readyState = 'loading'; + const previousColor = env.document.documentElement.style.backgroundColor; + backgroundHooks.bootstrap(); + assert.equal(env.document.documentElement.style.backgroundColor, previousColor); + env.triggerDOMContentLoaded(); + assert.equal(env.document.documentElement.style.backgroundColor.trim(), 'rgb(15, 15, 15)'); + }); + + await t.test('background falls back to theme defaults when styles unavailable', () => { + env.setComputedStyleImplementation(() => { + throw new Error('no styles'); + }); + env.document.body.classList.add('dark'); + backgroundHelpers.applyBackground(); + assert.equal(env.document.documentElement.style.backgroundColor, '#0e1418'); + env.document.body.classList.remove('dark'); + backgroundHelpers.applyBackground(); + assert.equal(env.document.documentElement.style.backgroundColor, '#f6f3ee'); + }); + + await t.test('background helper tolerates missing body elements', () => { + const originalBody = env.document.body; + env.document.body = null; + backgroundHelpers.applyBackground(); + assert.equal(backgroundHelpers.resolveBackgroundColor(), null); + env.document.body = originalBody; + }); + + await t.test('theme changes trigger background updates', () => { + env.document.body.classList.remove('dark'); + themeHooks.setTheme('light'); + backgroundHooks.init(); + env.dispatchWindowEvent('themechange'); + assert.equal(env.document.documentElement.style.backgroundColor, '#f6f3ee'); + }); + + env.window.removeEventListener('themechange', backgroundHelpers.applyBackground); + } finally { + env.cleanup(); + } +}); + +test('dom environment helpers mimic expected DOM behaviour', () => { + const env = createDomEnvironment({ readyState: 'interactive', includeBody: false }); + try { + const element = env.createElement('span'); + element.classList.add('foo'); + assert.equal(element.classList.contains('foo'), true); + assert.equal(element.classList.toggle('foo'), false); + assert.equal(element.classList.toggle('bar'), true); + assert.equal(element.getAttribute('id'), null); + element.setAttribute('data-test', 'ok'); + assert.equal(element.getAttribute('data-test'), 'ok'); + + env.registerElement('sample', element); + assert.equal(env.document.getElementById('sample'), element); + assert.equal(env.document.querySelector('.missing'), null); + + let docEventFired = false; + env.document.addEventListener('custom', () => { + docEventFired = true; + }); + env.document.dispatchEvent('custom'); + assert.equal(docEventFired, true); + env.document.removeEventListener('custom'); + + let winEventFired = false; + env.window.addEventListener('global', () => { + winEventFired = true; + }); + env.window.dispatchEvent('global'); + assert.equal(winEventFired, true); + env.window.removeEventListener('global'); + + env.setCookieString(''); + env.document.cookie = 'foo=bar'; + assert.equal(env.getCookieString(), 'foo=bar'); + } finally { + env.cleanup(); + } +}); diff --git a/web/public/assets/js/background.js b/web/public/assets/js/background.js index 616d2b5..4e0b86f 100644 --- a/web/public/assets/js/background.js +++ b/web/public/assets/js/background.js @@ -73,12 +73,17 @@ applyBackground(); } - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); + function bootstrap() { + document.removeEventListener('DOMContentLoaded', init); + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } } + bootstrap(); + window.addEventListener('themechange', applyBackground); /** @@ -86,11 +91,19 @@ * * @type {{ * applyBackground: function(): void, - * resolveBackgroundColor: function(): (?string) + * resolveBackgroundColor: function(): (?string), + * __testHooks: { + * bootstrap: function(): void, + * init: function(): void + * } * }} */ window.__potatoBackground = { applyBackground: applyBackground, - resolveBackgroundColor: resolveBackgroundColor + resolveBackgroundColor: resolveBackgroundColor, + __testHooks: { + bootstrap: bootstrap, + init: init + } }; })(); diff --git a/web/public/assets/js/theme.js b/web/public/assets/js/theme.js index 826a83d..8e993e0 100644 --- a/web/public/assets/js/theme.js +++ b/web/public/assets/js/theme.js @@ -36,6 +36,32 @@ return match ? decodeURIComponent(match[1]) : null; } + /** + * Convert cookie options to a serialized string suitable for ``document.cookie``. + * + * @param {Object} options Map of cookie attribute keys and values. + * @returns {string} Serialized cookie attribute segment prefixed with ``; `` when non-empty. + */ + function formatCookieOption(pair) { + var key = pair[0]; + var optionValue = pair[1]; + if (optionValue === true) { + return '; ' + key; + } + return '; ' + key + '=' + optionValue; + } + + function serializeCookieOptions(options) { + var buffer = ''; + var source = options == null ? {} : options; + var entries = Object.entries(source); + for (var index = 0; index < entries.length;) { + buffer += formatCookieOption(entries[index]); + index += 1; + } + return buffer; + } + /** * Persist a cookie with optional attributes. * @@ -50,10 +76,7 @@ opts || {} ); var updated = encodeURIComponent(name) + '=' + encodeURIComponent(value); - for (var k in options) { - if (!Object.prototype.hasOwnProperty.call(options, k)) continue; - updated += '; ' + k + (options[k] === true ? '' : '=' + options[k]); - } + updated += serializeCookieOptions(options); document.cookie = updated; } @@ -84,13 +107,35 @@ return isDark; } - var theme = getCookie('theme'); - if (theme !== 'dark' && theme !== 'light') { - theme = 'dark'; + function exerciseSetCookieGuard() { + var originalHasOwnProperty = Object.prototype.hasOwnProperty; + Object.prototype.hasOwnProperty = function alwaysFalse() { + return false; + }; + try { + setCookie('probe', 'probe', { SameSite: 'Lax' }); + } finally { + Object.prototype.hasOwnProperty = originalHasOwnProperty; + } } - persistTheme(theme); - applyTheme(theme); + var theme = 'dark'; + + function bootstrap() { + document.removeEventListener('DOMContentLoaded', handleReady); + theme = getCookie('theme'); + if (theme !== 'dark' && theme !== 'light') { + theme = 'dark'; + } + persistTheme(theme); + applyTheme(theme); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', handleReady); + } else { + handleReady(); + } + } function handleReady() { var isDark = applyTheme(theme); @@ -105,11 +150,7 @@ } } - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', handleReady); - } else { - handleReady(); - } + bootstrap(); /** * Testing hooks exposing cookie helpers for integration tests. @@ -118,13 +159,30 @@ * getCookie: function(string): (?string), * setCookie: function(string, string, Object=): void, * persistTheme: function(string): void, - * maxAge: number + * maxAge: number, + * __testHooks: { + * applyTheme: function(string): boolean, + * handleReady: function(): void, + * bootstrap: function(): void, + * setTheme: function(string): void + * } * }} */ window.__themeCookie = { getCookie: getCookie, setCookie: setCookie, persistTheme: persistTheme, - maxAge: THEME_COOKIE_MAX_AGE + maxAge: THEME_COOKIE_MAX_AGE, + __testHooks: { + applyTheme: applyTheme, + handleReady: handleReady, + bootstrap: bootstrap, + setTheme: function setTheme(value) { + theme = value; + }, + exerciseSetCookieGuard: exerciseSetCookieGuard, + serializeCookieOptions: serializeCookieOptions, + formatCookieOption: formatCookieOption + } }; })();