main 1fb2421

theme.test.mjs RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 45

↑↓ moveenter openesc close