mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
Add comprehensive theme and background front-end tests (#302)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
292
web/public/assets/js/app/__tests__/dom-environment.js
Normal file
292
web/public/assets/js/app/__tests__/dom-environment.js
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
216
web/public/assets/js/app/__tests__/theme-background.test.js
Normal file
216
web/public/assets/js/app/__tests__/theme-background.test.js
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user