Add comprehensive theme and background front-end tests (#302)

This commit is contained in:
l5y
2025-10-12 14:35:53 +02:00
committed by GitHub
parent e6974a683a
commit 511e6d377c
6 changed files with 661 additions and 22 deletions

View File

@@ -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);
});

View File

@@ -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();
}

View File

@@ -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<string, MockElement>} 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;
}
};
}

View File

@@ -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<typeof createDomEnvironment>} 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();
}
});

View File

@@ -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
}
};
})();

View File

@@ -36,6 +36,32 @@
return match ? decodeURIComponent(match[1]) : null;
}
/**
* Convert cookie options to a serialized string suitable for ``document.cookie``.
*
* @param {Object<string, *>} 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<string, *>=): 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
}
};
})();