v0.29.0 55fcc8f

a11y.test.mjs RawHistoryFolder

1// Tests of the keyboard accessibility of static/js/ermite.js and files.js on
2// the small DOM of dom.mjs: the "go to" palette as a combobox, Escape in the
3// file finder, the focus kept through a live swap, and the preference that
4// turns the single-key shortcuts off. Run with
5//   node --test 'internal/adapters/web/jstest/*.test.mjs'
6import assert from 'node:assert/strict';
7import { test } from 'node:test';
8import { dom, El, el, flushFrames, installDom, settle } from './dom.mjs';
9
10// The few things ermite.js uses that dom.mjs does not have.
11El.prototype.prepend = function (...nodes) { this.nodes.unshift(...this.adopt(nodes)); };
12El.prototype.replaceWith = function (n) {
13  const p = this.parent;
14  const i = p.nodes.indexOf(this);
15  const [added] = p.adopt([n]);
16  p.nodes.splice(i, 1, added);
17  this.parent = null;
18};
19El.prototype.contains = function (e) { for (let n = e; n; n = n.parent) if (n === this) return true; return false; };
20Object.defineProperty(El.prototype, 'isConnected', { get() { let n = this; while (n.parent) n = n.parent; return n === dom.body; } });
21El.prototype.toggleAttribute = function (k, on) { if (on) this.setAttribute(k, ''); else this.removeAttribute(k); return on; };
22El.prototype.dispatchEvent = function (ev) { (this.events ||= []).push(ev.type); };
23// The focusable controls of a fragment, as the selector of ermiteLive says.
24const nativeAll = El.prototype.querySelectorAll;
25El.prototype.querySelectorAll = function (sel) {
26  if (sel === '[data-rows] > [data-cursor]') return this.querySelectorAll('li[data-cursor]'); // no cursor in these tests
27  if (sel === '[data-rows] > *') return [];
28  if (!sel.startsWith('a[href], button')) return nativeAll.call(this, sel);
29  const out = [];
30  const walk = (e) => e.children.forEach((c) => {
31    if ((c.tagName === 'A' && c.hasAttribute('href')) || ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY'].includes(c.tagName) || c.hasAttribute('tabindex')) out.push(c);
32    walk(c);
33  });
34  walk(this);
35  return out;
36};
37
38let active = null;
39// As in a browser, an element inside a hidden one does not take the focus.
40El.prototype.focus = function (opts) {
41  dom.focused.push({ el: this, opts });
42  if (!this.closest('[hidden]')) active = this;
43};
44const doc = installDom({ parseHTML: (text) => parsed[text] });
45const parsed = {};
46Object.defineProperty(doc, 'activeElement', { get: () => active ?? dom.body });
47doc.documentElement = { classList: { add() {} } };
48const query = { all: doc.querySelectorAll, one: doc.querySelector };
49doc.querySelectorAll = () => []; // the page-wide queries of the top of ermite.js
50doc.querySelector = () => null;
51
52const store = new Map();
53globalThis.localStorage = { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) };
54const listeners = {};
55globalThis.addEventListener = (type, f) => (listeners[type] ||= []).push(f);
56globalThis.removeEventListener = (type, f) => { listeners[type] = (listeners[type] || []).filter((g) => g !== f); };
57globalThis.scrollTo = () => {};
58globalThis.fetch = async () => ({ ok: true, json: async () => ({ paths: ['cmd/main.go', 'README.md'] }) });
59
60await import('../static/js/files.js');
61await import('../static/js/store.js'); // loaded before ermite.js, as in the page
62await import('../static/js/ermite.js');
63doc.querySelectorAll = query.all;
64doc.querySelector = query.one;
65const F = window.ermiteFinder;
66const K = window.ermiteKeys;
67
68// keydown runs an element handler, then the window ones unless stopped, as a
69// browser does.
70function keydown(target, key, elementHandler, extra = {}) {
71  const e = {
72    key, target, isComposing: false, prevented: false, stopped: false, blurred: false,
73    preventDefault() { this.prevented = true; },
74    stopPropagation() { this.stopped = true; },
75    ...extra,
76  };
77  target.closest ||= () => null;
78  elementHandler?.(e);
79  if (!e.stopped) for (const f of listeners.keydown) f(e);
80  return e;
81}
82
83// ---- the palette --------------------------------------------------------
84
85function palette() {
86  const li = (k, label) => el('li', { attrs: { 'data-k': k } }, el('a', { attrs: { href: '/' + label } }));
87  const items = [li('go home', 'home'), li('section files', 'files'), li('branch main', 'main')];
88  items[0].setAttribute('data-cursor', '');
89  const input = el('input', { id: 'cmd-q', attrs: { role: 'combobox', 'aria-controls': 'cmd-list' } });
90  const list = el('ul', { id: 'cmd-list' }, ...items);
91  dom.body.replaceChildren(input, list);
92  return { input, list, items };
93}
94
95test('the palette: options, selected option and aria-activedescendant follow the cursor', () => {
96  const p = palette();
97  window.ermiteCmd.sync();
98  assert.deepEqual(p.items.map((i) => i.getAttribute('role')), ['option', 'option', 'option']);
99  assert.equal(new Set(p.items.map((i) => i.id)).size, 3, 'unique ids');
100  assert.equal(p.input.getAttribute('aria-expanded'), 'true');
101  assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[0].id);
102  assert.deepEqual(p.items.map((i) => i.getAttribute('aria-selected')), ['true', 'false', 'false']);
103
104  window.ermite.cmdKey({ key: 'ArrowDown', preventDefault() {} });
105  assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[1].id);
106  assert.deepEqual(p.items.map((i) => i.getAttribute('aria-selected')), ['false', 'true', 'false']);
107  assert.ok(p.items[1].hasAttribute('data-cursor') && !p.items[0].hasAttribute('data-cursor'));
108
109  window.ermite.cmdKey({ key: 'ArrowUp', preventDefault() {} });
110  assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[0].id);
111});
112
113test('the palette: a filter moves the active option, no match empties it and collapses the popup', () => {
114  const p = palette();
115  p.input.value = 'branch';
116  window.ermite.cmdFilter(p.input);
117  assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[2].id);
118  assert.equal(p.items[2].getAttribute('aria-selected'), 'true');
119  assert.equal(p.items[0].getAttribute('aria-selected'), 'false');
120  p.input.value = 'zzz';
121  window.ermite.cmdFilter(p.input);
122  assert.equal(p.input.getAttribute('aria-expanded'), 'false');
123  assert.equal(p.input.getAttribute('aria-activedescendant'), null);
124});
125
126test('the palette: the file hits that arrive later get ids, roles and the active option', async () => {
127  const p = palette();
128  dom.body.setAttribute('data-paths', '/paths.json');
129  dom.body.setAttribute('data-blob', '/blob/main');
130  p.input.value = 'main';
131  window.ermite.cmdFilter(p.input); // the branch "main" is the active option
132  assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[2].id);
133  F.palette(p.input);
134  flushFrames();
135  await settle();
136  const hits = p.list.children.filter((li) => li.className === 'finder-hit');
137  assert.ok(hits.length > 0, 'a file hit came');
138  for (const h of hits) {
139    assert.equal(h.getAttribute('role'), 'option');
140    assert.ok(h.id);
141  }
142  assert.equal(p.list.children.length, new Set(p.list.children.map((i) => i.id)).size, 'ids stay unique');
143  // down to the hit: the field points at it
144  window.ermite.cmdKey({ key: 'ArrowDown', preventDefault() {} });
145  assert.equal(p.input.getAttribute('aria-activedescendant'), hits[0].id);
146  assert.equal(hits[0].getAttribute('aria-selected'), 'true');
147  dom.body.removeAttribute('data-paths');
148});
149
150// ---- the file finder ----------------------------------------------------
151
152function finder() {
153  const q = el('input', { id: 'finder-q', attrs: { role: 'combobox' } });
154  const rows = ['a', 'b'].map((n) => el('tr', { attrs: { 'data-nm': n } }));
155  rows[0].setAttribute('data-cursor', '');
156  const treeRows = el('tbody', { id: 'tree-rows', attrs: { 'data-rows': '' } }, ...rows);
157  const list = el('ul', { id: 'finder-list' });
158  dom.body.replaceChildren(q, el('div', { id: 'tree-view' }, treeRows), el('div', { id: 'finder', hidden: true }, el('p', { id: 'finder-status' }), list));
159  dom.body.setAttribute('data-paths', '/paths.json');
160  dom.body.setAttribute('data-blob', '/blob/main');
161  return { q, list };
162}
163
164test('Escape in the finder clears the query, keeps the focus in the field and does not reach the global handler', async () => {
165  const f = finder();
166  f.q.value = 'main';
167  F.input(f.q);
168  flushFrames();
169  await settle();
170  active = f.q;
171  let blurred = false;
172  f.q.blur = () => { blurred = true; };
173  f.q.closest = () => f.q; // the global handler sees a field being typed in
174  const e = keydown(f.q, 'Escape', (ev) => F.key(f.q, ev));
175  assert.equal(f.q.value, '');
176  assert.equal(e.stopped, true);
177  assert.equal(e.prevented, true);
178  assert.equal(blurred, false, 'the field keeps the focus');
179  assert.equal(active, f.q);
180  // Escape again, with nothing typed: the field is left (global handler)
181  const e2 = keydown(f.q, 'Escape', (ev) => F.key(f.q, ev));
182  assert.equal(e2.stopped, false);
183  assert.equal(blurred, true);
184});
185
186test('the finder follows the cursor moved from outside (j / k)', async () => {
187  const f = finder();
188  f.q.value = 'm';
189  F.input(f.q);
190  flushFrames();
191  await settle();
192  const [first, second] = f.list.children;
193  assert.equal(f.q.getAttribute('aria-activedescendant'), first.id);
194  // ermite.move: the cursor goes to the next option
195  first.removeAttribute('data-cursor');
196  second.setAttribute('data-cursor', '');
197  F.sync();
198  assert.equal(second.getAttribute('aria-selected'), 'true');
199  assert.equal(first.getAttribute('aria-selected'), 'false');
200  assert.equal(f.q.getAttribute('aria-activedescendant'), second.id);
201});
202
203// ---- the focus through a live swap --------------------------------------
204
205function rows() {
206  const mk = (id, href) => el('li', { id }, el('a', { attrs: { href }, id: id + '-a' }), el('button', { id: id + '-b' }));
207  const main = el('main', { id: 'main', attrs: { tabindex: '-1' } });
208  const list = el('ul', { id: 'repo-rows' }, mk('repo-a', '/a'), mk('repo-b', '/b'), mk('repo-c', '/c'));
209  dom.body.replaceChildren(main, list);
210  return { main, list, mk };
211}
212const swapWith = (html, ...nodes) => { parsed[html] = nodes; window.ermiteLive.swap(html); };
213
214test('a swap that replaces the focused row puts the focus on the same control of the new one', () => {
215  const r = rows();
216  const old = r.list.children[1];
217  active = old.children[0];
218  dom.focused = [];
219  const fresh = r.mk('repo-b', '/b');
220  swapWith('b1', fresh);
221  assert.equal(active, fresh.children[0], 'the link with the same href');
222  assert.equal(dom.focused.at(-1).opts.preventScroll, true);
223  // the second control
224  active = fresh.children[1];
225  const fresh2 = r.mk('repo-b', '/b');
226  swapWith('b2', fresh2);
227  assert.equal(active, fresh2.children[1], 'the button with the same id');
228});
229
230test('a swap with the focus elsewhere does not touch it', () => {
231  const r = rows();
232  active = r.list.children[0].children[0];
233  dom.focused = [];
234  swapWith('b3', r.mk('repo-b', '/b'));
235  assert.equal(dom.focused.length, 0);
236  assert.equal(active, r.list.children[0].children[0]);
237});
238
239test('a removed row hands the focus to the next row, else the previous, else the content', () => {
240  const r = rows();
241  active = r.list.children[1].children[0];
242  const gone = (id) => { const g = el('li', { id, attrs: { 'data-gone': '' } }); return g; };
243  swapWith('g1', gone('repo-b'));
244  assert.equal(active, r.list.children[1].children[0], 'repo-c, which took its place');
245  assert.equal(r.list.children[1].id, 'repo-c');
246  active = r.list.children[1].children[0];
247  swapWith('g2', gone('repo-c'));
248  assert.equal(active, r.list.children[0].children[0], 'repo-a, the previous row');
249  swapWith('g3', gone('repo-a'));
250  // focus was on repo-a's link? it was handed there: now it is removed too
251  assert.equal(active, r.main);
252});
253
254test('a removed row skips the rows a filter hides', () => {
255  const r = rows();
256  const [a, b, c] = r.list.children;
257  b.hidden = true; // filtered out
258  active = a.children[0];
259  swapWith('g4', el('li', { id: 'repo-a', attrs: { 'data-gone': '' } }));
260  assert.equal(active, c.children[0], 'repo-c, the next shown row, not the hidden repo-b');
261  c.hidden = true;
262  swapWith('g5', el('li', { id: 'repo-c', attrs: { 'data-gone': '' } }));
263  assert.equal(active, r.main, 'no shown row left: the content');
264});
265
266// ---- single-character shortcuts -----------------------------------------
267
268test('shortcuts off: single characters do nothing, ctrl k, escape and enter still work', () => {
269  const events = () => dom.body.events.splice(0);
270  dom.body.events = [];
271  doc.querySelector = () => null;
272  const press = (key, extra) => keydown({ closest: () => null }, key, null, extra);
273  press('j');
274  assert.deepEqual(events(), ['k-j'], 'on by default');
275
276  K.set(true);
277  assert.equal(store.get('keys-off'), '1', 'persisted');
278  for (const k of ['j', 'k', '?', ':', 'G', 's', 'y']) press(k);
279  press('g'); press('h');
280  assert.deepEqual(events(), [], 'nothing fires');
281  press('k', { ctrlKey: true });
282  press('Escape');
283  assert.deepEqual(events(), ['k-cmd', 'k-esc']);
284
285  K.set(false);
286  assert.equal(store.get('keys-off'), '', 'persisted off');
287  press('j');
288  assert.deepEqual(events(), ['k-j']);
289});
290
291test('shortcuts off: the checkbox, the note and the palette action show the state', () => {
292  const box = el('input', { id: 'keys-off', attrs: { type: 'checkbox' } });
293  const note = el('p', { id: 'keys-note' });
294  const lbl = el('span', { cls: 'lbl' });
295  const list = el('ul', { id: 'cmd-list' }, el('li', {}, el('a', { attrs: { 'data-fire': 'keys' } }, lbl)));
296  dom.body.replaceChildren(box, note, list);
297  const find = doc.querySelector;
298  doc.querySelector = (sel) => (sel === '#cmd-list [data-fire="keys"] .lbl' ? lbl : null);
299  K.set(true);
300  assert.equal(box.checked, true);
301  assert.match(note.textContent, /are off/);
302  assert.match(lbl.textContent, /on$/);
303  K.set(false);
304  assert.equal(box.checked, false);
305  assert.match(note.textContent, /are on/);
306  assert.match(lbl.textContent, /off$/);
307  doc.querySelector = find;
308});
309
310test('shortcuts off: another tab\'s change applies here', () => {
311  // The browser has written the value before the event comes.
312  const storage = (newValue) => {
313    if (newValue === null) store.delete('keys-off'); else store.set('keys-off', newValue);
314    listeners.storage.forEach((f) => f({ key: 'keys-off', newValue }));
315  };
316  storage('1');
317  assert.equal(K.off(), true);
318  storage('');
319  assert.equal(K.off(), false);
320  storage(null);
321  assert.equal(K.off(), false, 'removed: on');
322});
323
324test('the preference is read from storage when the page loads', async () => {
325  store.set('keys-off', '1');
326  listeners.keydown = [];
327  const src = await import('node:fs').then((m) => m.readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8'));
328  doc.querySelectorAll = () => [];
329  doc.querySelector = () => null;
330  new Function(src)(); // a second page load
331  assert.equal(window.ermiteKeys.off(), true);
332  dom.body.events = [];
333  keydown({ closest: () => null }, 'j');
334  assert.deepEqual(dom.body.events, []);
335  store.set('keys-off', '');
336});
337
338
339test('activity moves a home row up only in newest activity order, preserving focus and cursor', () => {
340  for (const [sort, desc, activity, moves] of [
341    ['activity', 'true', true, true],
342    ['activity', 'false', true, false],
343    ['name', 'true', true, false],
344    ['name', 'false', true, false],
345    ['activity', 'true', false, false], // CI or metadata, not activity
346  ]) {
347    const r = rows();
348    r.list.dataset.sort = sort;
349    r.list.dataset.desc = desc;
350    const old = r.list.children[1];
351    old.setAttribute('data-cursor', '');
352    active = old.children[0];
353    const fresh = r.mk('repo-b', '/b');
354    if (activity) fresh.setAttribute('data-activity', '');
355    swapWith(`activity-${sort}-${desc}-${activity}`, fresh);
356    assert.equal(r.list.children.indexOf(fresh), moves ? 0 : 1);
357    assert.equal(active, fresh.children[0]);
358    assert.ok(fresh.hasAttribute('data-cursor'));
359  }
360});
361
362test('mobile MR layout shortcuts preserve the saved preference until back on desktop', () => {
363  const diffs = el('div', { id: 'diffs', attrs: { 'data-mr': '', 'data-diff': 'split', 'data-mobile': 'true' } });
364  dom.body.replaceChildren(diffs);
365  const oldQuery = doc.querySelector;
366  doc.querySelector = query.one;
367  const previous = globalThis.sessionStorage;
368  const writes = [];
369  globalThis.sessionStorage = { setItem: (...args) => writes.push(args) };
370  try {
371    window.ermite.layout('unified');
372    window.ermite.layout('split');
373    assert.equal(diffs.dataset.diff, 'split');
374    assert.deepEqual(writes, []);
375    diffs.dataset.mobile = 'false';
376    window.ermite.layout('unified');
377    assert.equal(diffs.dataset.diff, 'unified');
378    assert.deepEqual(writes, [['diff', 'unified']]);
379  } finally {
380    globalThis.sessionStorage = previous;
381    doc.querySelector = oldQuery;
382  }
383});

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