| @@ -0,0 +1,151 @@ |
| 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' |
| 7 | + | import assert from 'node:assert/strict'; |
| 8 | + | import { readFileSync } from 'node:fs'; |
| 9 | + | import { beforeEach, test } from 'node:test'; |
| 10 | + | import { el, installDom } from './dom.mjs'; |
| 11 | + | |
| 12 | + | const read = (f) => readFileSync(new URL('../static/js/' + f, import.meta.url), 'utf8'); |
| 13 | + | const ermite = read('ermite.js'); |
| 14 | + | const start = ermite.indexOf(' window.ermiteThemeSwitch = '); |
| 15 | + | const block = ermite.slice(start, ermite.indexOf(' })();', start) + ' })();'.length); |
| 16 | + | const storeSrc = read('store.js'); |
| 17 | + | |
| 18 | + | const doc = installDom(); |
| 19 | + | let listeners; |
| 20 | + | let storage; |
| 21 | + | let root; |
| 22 | + | let scheme; |
| 23 | + | let colors; |
| 24 | + | let button; |
| 25 | + | let 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). |
| 30 | + | function 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 | + | |
| 55 | + | const state = () => ({ |
| 56 | + | attr: root.getAttribute('data-theme'), |
| 57 | + | scheme: scheme.getAttribute('content'), |
| 58 | + | colors: colors.map((m) => m.getAttribute('content')), |
| 59 | + | }); |
| 60 | + | const click = (target) => listeners.click.forEach((f) => f({ target })); |
| 61 | + | const pageshow = (persisted) => listeners.pageshow.forEach((f) => f({ persisted })); |
| 62 | + | const storageEvent = (newValue) => listeners.storage.forEach((f) => f({ key: 'theme', newValue, storageArea: localStorage })); |
| 63 | + | |
| 64 | + | beforeEach(() => { delete globalThis.localStorage; }); |
| 65 | + | |
| 66 | + | test('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 | + | |
| 72 | + | test('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 | + | |
| 79 | + | test('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 | + | |
| 85 | + | test('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 | + | |
| 91 | + | test('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 | + | |
| 111 | + | test('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 | + | |
| 121 | + | test('other clicks do nothing', () => { |
| 122 | + | const T = load(); |
| 123 | + | click(doc.body); |
| 124 | + | click({ closest: undefined }); |
| 125 | + | assert.equal(T.get(), 'auto'); |
| 126 | + | }); |
| 127 | + | |
| 128 | + | test("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 | + | |
| 141 | + | test('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 | + | }); |