detached fda3c2b

layout.test.mjs RawHistoryFolder

1// Tests of the home page layout of static/js/ermite.js (window.ermiteLayout)
2// with store.js, on the small DOM of dom.mjs. Only the ermiteLayout block is
3// evaluated: the rest needs a page. Run with
4//   node --test 'internal/adapters/web/jstest/*.test.mjs'
5import assert from 'node:assert/strict';
6import { readFileSync } from 'node:fs';
7import { beforeEach, test } from 'node:test';
8import { el, installDom } from './dom.mjs';
9
10const read = (f) => readFileSync(new URL('../static/js/' + f, import.meta.url), 'utf8');
11const ermite = read('ermite.js');
12const start = ermite.indexOf('  window.ermiteLayout = ');
13const block = ermite.slice(start, ermite.indexOf('  })();', start) + '  })();'.length);
14const storeSrc = read('store.js');
15
16const doc = installDom();
17let listeners;
18let storage;
19let fits;
20let home;
21let buttons;
22
23// A page: .home and the switch, with "cards" pressed as the server renders it.
24// Then store.js and the block are evaluated, as a page load does.
25function load({ stored, storageObj } = {}) {
26  listeners = {};
27  fits = 0;
28  storage = storageObj ?? new Map(stored ? Object.entries(stored) : []);
29  globalThis.localStorage = storageObj ?? {
30    getItem: (k) => (storage.has(k) ? storage.get(k) : null),
31    setItem: (k, v) => { storage.set(k, String(v)); },
32    removeItem: (k) => { storage.delete(k); },
33  };
34  globalThis.addEventListener = (type, f) => (listeners[type] ||= []).push(f);
35  globalThis.removeEventListener = (type, f) => { listeners[type] = (listeners[type] || []).filter((g) => g !== f); };
36  home = el('div', { cls: 'home', attrs: { 'data-layout': 'cards' } });
37  buttons = ['cards', 'split', 'compact'].map((v) => el('button', {
38    cls: 'btn icon', attrs: { type: 'button', 'data-set-layout': v, 'aria-pressed': String(v === 'cards') },
39  }));
40  const sw = el('div', { cls: 'layout-switch', attrs: { role: 'group', 'data-js': '' } }, ...buttons);
41  doc.body.replaceChildren(sw, home);
42  window.ermiteFit = { all: () => { fits++; } };
43  new Function(storeSrc)();
44  new Function(block)();
45  return window.ermiteLayout;
46}
47
48const pressed = () => buttons.map((b) => b.getAttribute('aria-pressed'));
49const click = (target) => listeners.click.forEach((f) => f({ target }));
50
51beforeEach(() => { delete globalThis.localStorage; });
52
53test('with nothing stored the layout is cards, and the page is as rendered', () => {
54  const L = load();
55  assert.equal(L.get(), 'cards');
56  assert.deepEqual(L.values, ['cards', 'split', 'compact']);
57  assert.equal(home.getAttribute('data-layout'), 'cards');
58  assert.deepEqual(pressed(), ['true', 'false', 'false']);
59});
60
61test('the stored layout is applied when the page loads', () => {
62  const L = load({ stored: { 'home-layout': 'compact' } });
63  assert.equal(L.get(), 'compact');
64  assert.equal(home.getAttribute('data-layout'), 'compact');
65  assert.deepEqual(pressed(), ['false', 'false', 'true']);
66  assert.ok(fits > 0, 'the chips are fitted again');
67});
68
69test('a stored value that is not a layout falls back to cards', () => {
70  const L = load({ stored: { 'home-layout': 'grid' } });
71  assert.equal(L.get(), 'cards');
72  assert.equal(home.getAttribute('data-layout'), 'cards');
73  assert.deepEqual(pressed(), ['true', 'false', 'false']);
74});
75
76test('set stores the layout, shows it and fits the chips again', () => {
77  const L = load();
78  fits = 0;
79  L.set('split');
80  assert.equal(storage.get('home-layout'), 'split');
81  assert.equal(home.getAttribute('data-layout'), 'split');
82  assert.deepEqual(pressed(), ['false', 'true', 'false']);
83  assert.equal(fits, 1);
84  L.set('nope');
85  assert.equal(L.get(), 'split', 'an unknown layout is ignored');
86  assert.equal(storage.get('home-layout'), 'split');
87});
88
89test('pressing a layout button sets it; other clicks do nothing', () => {
90  const L = load();
91  click(buttons[2]);
92  assert.equal(L.get(), 'compact');
93  assert.equal(home.getAttribute('data-layout'), 'compact');
94  assert.deepEqual(pressed(), ['false', 'false', 'true']);
95  click(home);
96  click({ closest: undefined });
97  assert.equal(L.get(), 'compact');
98  // a child of the button (an icon) counts as the button
99  const icon = el('svg');
100  buttons[1].append(icon);
101  click(icon);
102  assert.equal(L.get(), 'split');
103});
104
105test("another tab's change applies live", () => {
106  const L = load();
107  storage.set('home-layout', 'split');
108  listeners.storage.forEach((f) => f({ key: 'home-layout', newValue: 'split', storageArea: localStorage }));
109  assert.equal(L.get(), 'split');
110  assert.equal(home.getAttribute('data-layout'), 'split');
111  assert.deepEqual(pressed(), ['false', 'true', 'false']);
112  storage.delete('home-layout');
113  listeners.storage.forEach((f) => f({ key: 'home-layout', newValue: null, storageArea: localStorage }));
114  assert.equal(home.getAttribute('data-layout'), 'cards', 'removed: back to the default');
115  listeners.storage.forEach((f) => f({ key: 'keys-off', newValue: '1', storageArea: localStorage }));
116  assert.equal(home.getAttribute('data-layout'), 'cards');
117});
118
119test('a page without .home or without ermiteFit still works', () => {
120  const L = load();
121  doc.body.replaceChildren();
122  delete window.ermiteFit;
123  L.set('split');
124  assert.equal(L.get(), 'split');
125  L.apply();
126});
127
128test('without usable storage the choice holds for the page', () => {
129  const refusing = {
130    getItem: () => { throw new Error('denied'); },
131    setItem: () => { throw new Error('quota'); },
132    removeItem: () => { throw new Error('denied'); },
133  };
134  const L = load({ storageObj: refusing });
135  assert.equal(L.get(), 'cards');
136  click(buttons[1]);
137  assert.equal(L.get(), 'split');
138  assert.equal(home.getAttribute('data-layout'), 'split');
139});

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 2

Tags 45

↑↓ moveenter openesc close