1// Tests of the theme: window.ermiteTheme of static/js/store.js (applied before
2// the first paint) and the footer switch of static/js/ermite.js
3// (window.ermiteThemeSwitch), on the small DOM of dom.mjs. Only the
4// ermiteThemeSwitch block of ermite.js is evaluated: the rest needs a page.
5// Run with
6// node --test 'internal/adapters/web/jstest/*.test.mjs'
7import assert from 'node:assert/strict';
8import { readFileSync } from 'node:fs';
9import { beforeEach, test } from 'node:test';
10import { el, installDom } from './dom.mjs';
11
12const read = (f) => readFileSync(new URL('../static/js/' + f, import.meta.url), 'utf8');
13const ermite = read('ermite.js');
14const start = ermite.indexOf(' window.ermiteThemeSwitch = ');
15const block = ermite.slice(start, ermite.indexOf(' })();', start) + ' })();'.length);
16const storeSrc = read('store.js');
17
18const doc = installDom();
19let listeners;
20let storage;
21let root;
22let scheme;
23let colors;
24let button;
25let icon;
26
27// A page as shell.templ renders it: the metas and the footer button in auto.
28// Then store.js runs (in <head>) and, unless withSwitch is false, the block of
29// ermite.js (deferred).
30function load({ stored, withSwitch = true } = {}) {
31 listeners = {};
32 storage = new Map(stored ? Object.entries(stored) : []);
33 globalThis.localStorage = {
34 getItem: (k) => (storage.has(k) ? storage.get(k) : null),
35 setItem: (k, v) => { storage.set(k, String(v)); },
36 removeItem: (k) => { storage.delete(k); },
37 };
38 globalThis.addEventListener = (type, f) => (listeners[type] ||= []).push(f);
39 globalThis.removeEventListener = (type, f) => { listeners[type] = (listeners[type] || []).filter((g) => g !== f); };
40 root = el('html');
41 doc.documentElement = root;
42 scheme = el('meta', { attrs: { name: 'color-scheme', content: 'light dark' } });
43 colors = [
44 el('meta', { attrs: { name: 'theme-color', 'data-scheme': 'light', media: '(prefers-color-scheme: light)', content: '#ffffff' } }),
45 el('meta', { attrs: { name: 'theme-color', 'data-scheme': 'dark', media: '(prefers-color-scheme: dark)', content: '#0c0c0c' } }),
46 ];
47 icon = el('span', { cls: 'ico i-sun-moon', attrs: { 'aria-hidden': 'true' } });
48 button = el('button', { cls: 'foot-theme', attrs: { type: 'button', 'data-js': '', 'data-theme-switch': '' } }, icon);
49 doc.body.replaceChildren(scheme, ...colors, el('footer', { cls: 'foot' }, button));
50 new Function(storeSrc)();
51 if (withSwitch) new Function(block)();
52 return window.ermiteTheme;
53}
54
55const state = () => ({
56 attr: root.getAttribute('data-theme'),
57 scheme: scheme.getAttribute('content'),
58 colors: colors.map((m) => m.getAttribute('content')),
59});
60const click = (target) => listeners.click.forEach((f) => f({ target }));
61const pageshow = (persisted) => listeners.pageshow.forEach((f) => f({ persisted }));
62const storageEvent = (newValue) => listeners.storage.forEach((f) => f({ key: 'theme', newValue, storageArea: localStorage }));
63
64beforeEach(() => { delete globalThis.localStorage; });
65
66test('nothing stored: auto, the page is left to the system', () => {
67 const T = load({ withSwitch: false });
68 assert.equal(T.get(), 'auto');
69 assert.deepEqual(state(), { attr: null, scheme: 'light dark', colors: ['#ffffff', '#0c0c0c'] });
70});
71
72test('store.js applies a stored theme before ermite.js runs', () => {
73 load({ stored: { theme: 'dark' }, withSwitch: false });
74 assert.deepEqual(state(), { attr: 'dark', scheme: 'dark', colors: ['#0c0c0c', '#0c0c0c'] });
75 load({ stored: { theme: 'light' }, withSwitch: false });
76 assert.deepEqual(state(), { attr: 'light', scheme: 'light', colors: ['#ffffff', '#ffffff'] });
77});
78
79test('a stored value that is not a theme falls back to auto', () => {
80 const T = load({ stored: { theme: 'sepia' } });
81 assert.equal(T.get(), 'auto');
82 assert.equal(root.getAttribute('data-theme'), null);
83});
84
85test('the switch shows the stored theme on load', () => {
86 load({ stored: { theme: 'dark' } });
87 assert.equal(icon.className, 'ico i-moon');
88 assert.equal(button.getAttribute('aria-label'), 'theme: dark (switch to auto)');
89});
90
91test('a click on the button (or its icon) cycles, stores and shows the theme', () => {
92 load();
93 assert.equal(button.getAttribute('aria-label'), 'theme: auto (switch to light)');
94 click(icon);
95 assert.equal(storage.get('theme'), 'light');
96 assert.deepEqual(state(), { attr: 'light', scheme: 'light', colors: ['#ffffff', '#ffffff'] });
97 assert.equal(icon.className, 'ico i-sun');
98 assert.equal(button.getAttribute('aria-label'), 'theme: light (switch to dark)');
99 assert.equal(button.getAttribute('title'), 'theme: light (switch to dark)');
100 click(button);
101 assert.equal(storage.get('theme'), 'dark');
102 assert.deepEqual(state(), { attr: 'dark', scheme: 'dark', colors: ['#0c0c0c', '#0c0c0c'] });
103 assert.equal(icon.className, 'ico i-moon');
104 click(button);
105 assert.equal(storage.get('theme'), 'auto');
106 assert.deepEqual(state(), { attr: null, scheme: 'light dark', colors: ['#ffffff', '#0c0c0c'] });
107 assert.equal(icon.className, 'ico i-sun-moon');
108 assert.equal(button.getAttribute('aria-label'), 'theme: auto (switch to light)');
109});
110
111test('a page back from the back/forward cache shows the stored theme', () => {
112 load();
113 storage.set('theme', 'light');
114 pageshow(false);
115 assert.equal(root.getAttribute('data-theme'), null, 'a fresh load: store.js already applied it');
116 pageshow(true);
117 assert.deepEqual(state(), { attr: 'light', scheme: 'light', colors: ['#ffffff', '#ffffff'] });
118 assert.equal(icon.className, 'ico i-sun');
119});
120
121test('other clicks do nothing', () => {
122 const T = load();
123 click(doc.body);
124 click({ closest: undefined });
125 assert.equal(T.get(), 'auto');
126});
127
128test("another tab's change applies live", () => {
129 const T = load();
130 storage.set('theme', 'dark');
131 storageEvent('dark');
132 assert.equal(T.get(), 'dark');
133 assert.equal(root.getAttribute('data-theme'), 'dark');
134 assert.equal(icon.className, 'ico i-moon');
135 storage.delete('theme');
136 storageEvent(null);
137 assert.equal(root.getAttribute('data-theme'), null, 'removed: back to auto');
138 assert.equal(button.getAttribute('aria-label'), 'theme: auto (switch to light)');
139});
140
141test('without usable storage the choice holds for the page', () => {
142 load();
143 globalThis.localStorage = {
144 getItem: () => { throw new Error('denied'); },
145 setItem: () => { throw new Error('quota'); },
146 removeItem: () => { throw new Error('denied'); },
147 };
148 click(button);
149 assert.equal(window.ermiteTheme.get(), 'light');
150 assert.equal(root.getAttribute('data-theme'), 'light');
151});