v0.31.0 d5e0505

store.test.mjs RawHistoryFolder

1// Tests of static/js/store.js (window.ermiteStore): the localStorage client,
2// evaluated alone against a fake storage. Run with
3//   node --test 'internal/adapters/web/jstest/*.test.mjs'
4import assert from 'node:assert/strict';
5import { readFileSync } from 'node:fs';
6import { test } from 'node:test';
7
8const src = readFileSync(new URL('../static/js/store.js', import.meta.url), 'utf8');
9
10// load evaluates the file on a page whose storage is the given object (a
11// Map-backed one by default) and answers the store and the "storage" listeners.
12function load(storage = fakeStorage()) {
13  const listeners = new Set();
14  const g = globalThis;
15  g.window = g;
16  if (storage === 'missing') delete g.localStorage;
17  else g.localStorage = storage;
18  g.addEventListener = (type, f) => { if (type === 'storage') listeners.add(f); };
19  g.removeEventListener = (type, f) => { if (type === 'storage') listeners.delete(f); };
20  new Function(src)();
21  const emit = (e) => [...listeners].forEach((f) => f({ storageArea: g.localStorage, ...e }));
22  return { store: g.ermiteStore, listeners, emit };
23}
24
25function fakeStorage() {
26  const m = new Map();
27  return {
28    m,
29    getItem: (k) => (m.has(k) ? m.get(k) : null),
30    setItem: (k, v) => { m.set(k, String(v)); },
31    removeItem: (k) => { m.delete(k); },
32  };
33}
34
35const refusing = () => ({
36  getItem: () => { throw new Error('denied'); },
37  setItem: () => { throw new Error('quota'); },
38  removeItem: () => { throw new Error('denied'); },
39});
40
41test('get answers the stored value, else the fallback (null by default)', () => {
42  const st = fakeStorage();
43  const { store } = load(st);
44  assert.equal(store.get('k'), null);
45  assert.equal(store.get('k', 'x'), 'x');
46  st.m.set('k', 'v');
47  assert.equal(store.get('k', 'x'), 'v');
48  st.m.set('k', '');
49  assert.equal(store.get('k', 'x'), '', 'an empty string is a value');
50});
51
52test('set stores strings; null and undefined remove; remove forgets', () => {
53  const st = fakeStorage();
54  const { store } = load(st);
55  store.set('k', 'v');
56  assert.equal(st.m.get('k'), 'v');
57  store.set('n', 3);
58  assert.equal(st.m.get('n'), '3');
59  store.set('k', null);
60  assert.equal(st.m.has('k'), false);
61  store.set('k', 'v');
62  store.set('k', undefined);
63  assert.equal(st.m.has('k'), false);
64  store.remove('n');
65  assert.equal(store.get('n'), null);
66  store.remove('nothing'); // no error
67});
68
69test('without storage nothing throws and the value holds for the page', () => {
70  const { store } = load('missing');
71  assert.equal(store.get('k', 'x'), 'x');
72  store.set('k', 'v');
73  assert.equal(store.get('k', 'x'), 'v');
74  store.remove('k');
75  assert.equal(store.get('k', 'x'), 'x');
76});
77
78test('a storage that refuses reads and writes does not throw either', () => {
79  const { store } = load(refusing());
80  assert.equal(store.get('k', 'x'), 'x');
81  store.set('k', 'v');
82  assert.equal(store.get('k', 'x'), 'v', 'held in memory');
83  store.remove('k');
84  assert.equal(store.get('k', 'x'), 'x', 'removed for the page');
85});
86
87test('a quota error on write keeps the value for the page; a later write that works wins', () => {
88  const st = fakeStorage();
89  const { store } = load(st);
90  let full = true;
91  st.setItem = (k, v) => { if (full) throw new Error('quota'); st.m.set(k, v); };
92  store.set('k', 'a');
93  assert.equal(store.get('k'), 'a');
94  assert.equal(st.m.has('k'), false);
95  full = false;
96  store.set('k', 'b');
97  assert.equal(st.m.get('k'), 'b');
98  st.m.set('k', 'c'); // changed elsewhere: the held value is gone
99  assert.equal(store.get('k'), 'c');
100});
101
102test('a getter of localStorage that throws (sandboxed frame) is survived', () => {
103  const { store } = load('missing');
104  Object.defineProperty(globalThis, 'localStorage', { get() { throw new Error('SecurityError'); }, configurable: true });
105  try {
106    assert.equal(store.get('k', 'x'), 'x');
107    store.set('k', 'v');
108    assert.equal(store.get('k'), 'v');
109  } finally {
110    delete globalThis.localStorage;
111  }
112});
113
114test('watch calls back with the new value of its key only, and stops when told', () => {
115  const { store, emit, listeners } = load();
116  const seen = [];
117  const stop = store.watch('k', (v) => seen.push(v));
118  emit({ key: 'other', newValue: 'z' });
119  emit({ key: 'k', newValue: 'v' });
120  emit({ key: 'k', newValue: null });
121  emit({ key: null, newValue: null }); // the other tab cleared everything
122  assert.deepEqual(seen, ['v', null, null]);
123  stop();
124  assert.equal(listeners.size, 0);
125  emit({ key: 'k', newValue: 'w' });
126  assert.deepEqual(seen, ['v', null, null]);
127});
128
129test('watch ignores the events of another storage area (sessionStorage)', () => {
130  const { store, emit } = load();
131  const seen = [];
132  store.watch('k', (v) => seen.push(v));
133  emit({ key: 'k', newValue: 'v', storageArea: {} });
134  assert.deepEqual(seen, []);
135});
136
137test('an event from another tab replaces a value held for this page', () => {
138  const { store, emit } = load(refusing());
139  store.set('k', 'mine');
140  store.watch('k', () => {});
141  emit({ key: 'k', newValue: 'theirs', storageArea: undefined });
142  assert.equal(store.get('k'), null, 'the held value is dropped; storage is the truth');
143});
144
145test('pref answers only values of its list, else the fallback', () => {
146  const st = fakeStorage();
147  const { store } = load(st);
148  const p = store.pref('layout', { values: ['a', 'b'], fallback: 'a' });
149  assert.equal(p.get(), 'a', 'nothing stored');
150  p.set('b');
151  assert.equal(st.m.get('layout'), 'b');
152  assert.equal(p.get(), 'b');
153  p.set('zzz'); // not allowed: ignored
154  assert.equal(p.get(), 'b');
155  st.m.set('layout', 'old-value');
156  assert.equal(p.get(), 'a', 'an invalid stored value falls back');
157});
158
159test('pref works with the empty string as a value (keys-off)', () => {
160  const st = fakeStorage();
161  const { store } = load(st);
162  const p = store.pref('keys-off', { values: ['1', ''], fallback: '' });
163  assert.equal(p.get(), '');
164  p.set('1');
165  assert.equal(st.m.get('keys-off'), '1');
166  p.set('');
167  assert.equal(st.m.get('keys-off'), '');
168  assert.equal(p.get(), '');
169});
170
171test('pref.watch gives validated values', () => {
172  const { store, emit } = load();
173  const p = store.pref('layout', { values: ['a', 'b'], fallback: 'a' });
174  const seen = [];
175  const stop = p.watch((v) => seen.push(v));
176  emit({ key: 'layout', newValue: 'b' });
177  emit({ key: 'layout', newValue: 'junk' });
178  emit({ key: 'layout', newValue: null });
179  assert.deepEqual(seen, ['b', 'a', 'a']);
180  stop();
181  emit({ key: 'layout', newValue: 'b' });
182  assert.equal(seen.length, 3);
183});

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 4

Tags 45

↑↓ moveenter openesc close