v0.34.0 7c1886f

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: { 'data-href': '/' + label, role: 'presentation' } }));
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.equal(h.querySelector('a[href]'), null);
141    assert.equal(h.querySelector('a').hasAttribute('tabindex'), false);
142    assert.ok(h.id);
143  }
144  assert.equal(p.list.children.length, new Set(p.list.children.map((i) => i.id)).size, 'ids stay unique');
145  // down to the hit: the field points at it
146  window.ermite.cmdKey({ key: 'ArrowDown', preventDefault() {} });
147  assert.equal(p.input.getAttribute('aria-activedescendant'), hits[0].id);
148  assert.equal(hits[0].getAttribute('aria-selected'), 'true');
149  dom.body.removeAttribute('data-paths');
150});
151
152// ---- the file finder ----------------------------------------------------
153
154function finder() {
155  const q = el('input', { id: 'finder-q', attrs: { role: 'combobox' } });
156  const rows = ['a', 'b'].map((n) => el('tr', { attrs: { 'data-nm': n } }));
157  rows[0].setAttribute('data-cursor', '');
158  const treeRows = el('tbody', { id: 'tree-rows', attrs: { 'data-rows': '' } }, ...rows);
159  const list = el('ul', { id: 'finder-list' });
160  dom.body.replaceChildren(q, el('div', { id: 'tree-view' }, treeRows), el('div', { id: 'finder', hidden: true }, el('p', { id: 'finder-status' }), list));
161  dom.body.setAttribute('data-paths', '/paths.json');
162  dom.body.setAttribute('data-blob', '/blob/main');
163  return { q, list };
164}
165
166test('Escape in the finder clears the query, keeps the focus in the field and does not reach the global handler', async () => {
167  const f = finder();
168  f.q.value = 'main';
169  F.input(f.q);
170  flushFrames();
171  await settle();
172  active = f.q;
173  let blurred = false;
174  f.q.blur = () => { blurred = true; };
175  f.q.closest = () => f.q; // the global handler sees a field being typed in
176  const e = keydown(f.q, 'Escape', (ev) => F.key(f.q, ev));
177  assert.equal(f.q.value, '');
178  assert.equal(doc.getElementById('finder-status').textContent, '');
179  assert.equal(e.stopped, true);
180  assert.equal(e.prevented, true);
181  assert.equal(blurred, false, 'the field keeps the focus');
182  assert.equal(active, f.q);
183  // Escape again, with nothing typed: the field is left (global handler)
184  const e2 = keydown(f.q, 'Escape', (ev) => F.key(f.q, ev));
185  assert.equal(e2.stopped, false);
186  assert.equal(blurred, true);
187});
188
189test('the finder follows the cursor moved from outside (j / k)', async () => {
190  const f = finder();
191  f.q.value = 'm';
192  F.input(f.q);
193  flushFrames();
194  await settle();
195  const [first, second] = f.list.children;
196  assert.equal(f.q.getAttribute('aria-activedescendant'), first.id);
197  // ermite.move: the cursor goes to the next option
198  first.removeAttribute('data-cursor');
199  second.setAttribute('data-cursor', '');
200  F.sync();
201  assert.equal(second.getAttribute('aria-selected'), 'true');
202  assert.equal(first.getAttribute('aria-selected'), 'false');
203  assert.equal(f.q.getAttribute('aria-activedescendant'), second.id);
204});
205
206// ---- the focus through a live swap --------------------------------------
207
208function rows() {
209  const mk = (id, href) => el('li', { id }, el('a', { attrs: { href }, id: id + '-a' }), el('button', { id: id + '-b' }));
210  const main = el('main', { id: 'main', attrs: { tabindex: '-1' } });
211  const list = el('ul', { id: 'repo-rows' }, mk('repo-a', '/a'), mk('repo-b', '/b'), mk('repo-c', '/c'));
212  dom.body.replaceChildren(main, list);
213  return { main, list, mk };
214}
215const swapWith = (html, ...nodes) => { parsed[html] = nodes; window.ermiteLive.swap(html); };
216
217test('a swap that replaces the focused row puts the focus on the same control of the new one', () => {
218  const r = rows();
219  const old = r.list.children[1];
220  active = old.children[0];
221  dom.focused = [];
222  const fresh = r.mk('repo-b', '/b');
223  swapWith('b1', fresh);
224  assert.equal(active, fresh.children[0], 'the link with the same href');
225  assert.equal(dom.focused.at(-1).opts.preventScroll, true);
226  // the second control
227  active = fresh.children[1];
228  const fresh2 = r.mk('repo-b', '/b');
229  swapWith('b2', fresh2);
230  assert.equal(active, fresh2.children[1], 'the button with the same id');
231});
232
233test('a swap with the focus elsewhere does not touch it', () => {
234  const r = rows();
235  active = r.list.children[0].children[0];
236  dom.focused = [];
237  swapWith('b3', r.mk('repo-b', '/b'));
238  assert.equal(dom.focused.length, 0);
239  assert.equal(active, r.list.children[0].children[0]);
240});
241
242test('a removed row hands the focus to the next row, else the previous, else the content', () => {
243  const r = rows();
244  active = r.list.children[1].children[0];
245  const gone = (id) => { const g = el('li', { id, attrs: { 'data-gone': '' } }); return g; };
246  swapWith('g1', gone('repo-b'));
247  assert.equal(active, r.list.children[1].children[0], 'repo-c, which took its place');
248  assert.equal(r.list.children[1].id, 'repo-c');
249  active = r.list.children[1].children[0];
250  swapWith('g2', gone('repo-c'));
251  assert.equal(active, r.list.children[0].children[0], 'repo-a, the previous row');
252  swapWith('g3', gone('repo-a'));
253  // focus was on repo-a's link? it was handed there: now it is removed too
254  assert.equal(active, r.main);
255});
256
257test('a removed row skips the rows a filter hides', () => {
258  const r = rows();
259  const [a, b, c] = r.list.children;
260  b.hidden = true; // filtered out
261  active = a.children[0];
262  swapWith('g4', el('li', { id: 'repo-a', attrs: { 'data-gone': '' } }));
263  assert.equal(active, c.children[0], 'repo-c, the next shown row, not the hidden repo-b');
264  c.hidden = true;
265  swapWith('g5', el('li', { id: 'repo-c', attrs: { 'data-gone': '' } }));
266  assert.equal(active, r.main, 'no shown row left: the content');
267});
268
269// ---- single-character shortcuts -----------------------------------------
270
271test('shortcuts off: single characters do nothing, ctrl k, escape and enter still work', () => {
272  const events = () => dom.body.events.splice(0);
273  dom.body.events = [];
274  doc.querySelector = () => null;
275  const press = (key, extra) => keydown({ closest: () => null }, key, null, extra);
276  press('j');
277  assert.deepEqual(events(), ['k-j'], 'on by default');
278
279  K.set(true);
280  assert.equal(store.get('keys-off'), '1', 'persisted');
281  for (const k of ['j', 'k', '?', ':', 'G', 's', 'y']) press(k);
282  press('g'); press('h');
283  assert.deepEqual(events(), [], 'nothing fires');
284  press('k', { ctrlKey: true });
285  press('Escape');
286  assert.deepEqual(events(), ['k-cmd', 'k-esc']);
287
288  K.set(false);
289  assert.equal(store.get('keys-off'), '', 'persisted off');
290  press('j');
291  assert.deepEqual(events(), ['k-j']);
292});
293
294test('shortcuts off: the checkbox, the note and the palette action show the state', () => {
295  const box = el('input', { id: 'keys-off', attrs: { type: 'checkbox' } });
296  const note = el('p', { id: 'keys-note' });
297  const lbl = el('span', { cls: 'lbl' });
298  const list = el('ul', { id: 'cmd-list' }, el('li', {}, el('a', { attrs: { 'data-fire': 'keys' } }, lbl)));
299  dom.body.replaceChildren(box, note, list);
300  const find = doc.querySelector;
301  doc.querySelector = (sel) => (sel === '#cmd-list [data-fire="keys"] .lbl' ? lbl : null);
302  K.set(true);
303  assert.equal(box.checked, true);
304  assert.match(note.textContent, /are off/);
305  assert.match(lbl.textContent, /on$/);
306  K.set(false);
307  assert.equal(box.checked, false);
308  assert.match(note.textContent, /are on/);
309  assert.match(lbl.textContent, /off$/);
310  doc.querySelector = find;
311});
312
313test('shortcuts off: another tab\'s change applies here', () => {
314  // The browser has written the value before the event comes.
315  const storage = (newValue) => {
316    if (newValue === null) store.delete('keys-off'); else store.set('keys-off', newValue);
317    listeners.storage.forEach((f) => f({ key: 'keys-off', newValue }));
318  };
319  storage('1');
320  assert.equal(K.off(), true);
321  storage('');
322  assert.equal(K.off(), false);
323  storage(null);
324  assert.equal(K.off(), false, 'removed: on');
325});
326
327test('the preference is read from storage when the page loads', async () => {
328  store.set('keys-off', '1');
329  listeners.keydown = [];
330  const src = await import('node:fs').then((m) => m.readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8'));
331  doc.querySelectorAll = () => [];
332  doc.querySelector = () => null;
333  new Function(src)(); // a second page load
334  assert.equal(window.ermiteKeys.off(), true);
335  dom.body.events = [];
336  keydown({ closest: () => null }, 'j');
337  assert.deepEqual(dom.body.events, []);
338  store.set('keys-off', '');
339});
340
341
342test('activity moves a home row up only in newest activity order, preserving focus and cursor', () => {
343  for (const [sort, desc, activity, moves] of [
344    ['activity', 'true', true, true],
345    ['activity', 'false', true, false],
346    ['name', 'true', true, false],
347    ['name', 'false', true, false],
348    ['activity', 'true', false, false], // CI or metadata, not activity
349  ]) {
350    const r = rows();
351    r.list.dataset.sort = sort;
352    r.list.dataset.desc = desc;
353    const old = r.list.children[1];
354    old.setAttribute('data-cursor', '');
355    active = old.children[0];
356    const fresh = r.mk('repo-b', '/b');
357    if (activity) fresh.setAttribute('data-activity', '');
358    swapWith(`activity-${sort}-${desc}-${activity}`, fresh);
359    assert.equal(r.list.children.indexOf(fresh), moves ? 0 : 1);
360    assert.equal(active, fresh.children[0]);
361    assert.ok(fresh.hasAttribute('data-cursor'));
362  }
363});
364
365test('mobile MR layout shortcuts preserve the saved preference until back on desktop', () => {
366  const diffs = el('div', { id: 'diffs', attrs: { 'data-mr': '', 'data-diff': 'split', 'data-mobile': 'true' } });
367  dom.body.replaceChildren(diffs);
368  const oldQuery = doc.querySelector;
369  doc.querySelector = query.one;
370  const previous = globalThis.sessionStorage;
371  const writes = [];
372  globalThis.sessionStorage = { setItem: (...args) => writes.push(args) };
373  try {
374    window.ermite.layout('unified');
375    window.ermite.layout('split');
376    assert.equal(diffs.dataset.diff, 'split');
377    assert.deepEqual(writes, []);
378    diffs.dataset.mobile = 'false';
379    window.ermite.layout('unified');
380    assert.equal(diffs.dataset.diff, 'unified');
381    assert.deepEqual(writes, [['diff', 'unified']]);
382  } finally {
383    globalThis.sessionStorage = previous;
384    doc.querySelector = oldQuery;
385  }
386});
387
388test('dialog restores its row opener on close, but not over a replacement dialog', () => {
389  const row = el('li', { tabindex: '0' });
390  const first = el('dialog', { id: 'focus-first' });
391  const second = el('dialog', { id: 'focus-second' });
392  dom.body.append(row, first, second);
393  const queued = [];
394  const all = doc.querySelectorAll;
395  const one = doc.querySelector;
396  const open = () => [first, second].filter((d) => d.hasAttribute('open'));
397  doc.querySelectorAll = (sel) => sel === 'dialog[open]' ? open() : all.call(doc, sel);
398  doc.querySelector = (sel) => sel === 'dialog[open]' ? open()[0] ?? null : one.call(doc, sel);
399  for (const d of [first, second]) {
400    let onClose;
401    d.addEventListener = (type, fn) => { if (type === 'close') onClose = fn; };
402    d.showModal = () => { d.setAttribute('open', ''); d.focus(); };
403    d.close = () => { d.removeAttribute('open'); queued.push(onClose); };
404  }
405  row.focus();
406  window.ermite.dialog('focus-first');
407  first.close();
408  queued.shift()();
409  assert.equal(doc.activeElement, row);
410
411  row.focus();
412  window.ermite.dialog('focus-first');
413  window.ermite.dialog('focus-second');
414  queued.shift()();
415  assert.equal(doc.activeElement, second);
416  second.close();
417  queued.shift()();
418  doc.querySelectorAll = all;
419  doc.querySelector = one;
420});
421
422
423test('listbox result wrappers have no links; Enter and delegated clicks navigate', async () => {
424  doc.querySelector = query.one;
425  doc.querySelectorAll = query.all;
426  const assigned = [];
427  location.assign = (href) => assigned.push(href);
428  const activate = (anchor) => {
429    const e = { target: anchor, button: 0, preventDefault() {} };
430    listeners.click.find((fn) => fn.toString().includes('location.assign'))(e);
431  };
432  const p = palette();
433  p.list.setAttribute('role', 'listbox');
434  window.ermiteCmd.sync();
435  for (const item of p.items) assert.equal(item.querySelector('a[href]'), null);
436  const a = p.items[0].querySelector('a');
437  a.click = () => activate(a);
438  window.ermite.cmdKey({ key: 'Enter', preventDefault() {} });
439  assert.deepEqual(assigned, ['/home']);
440  activate(p.items[1].querySelector('a'));
441  assert.deepEqual(assigned, ['/home', '/files']);
442
443  const f = finder();
444  f.list.setAttribute('role', 'listbox');
445  f.q.value = 'main';
446  F.input(f.q);
447  flushFrames();
448  await settle();
449  assert.equal(f.list.querySelector('a[href]'), null);
450  const hit = f.list.children[0].querySelector('a');
451  assert.equal(hit.hasAttribute('tabindex'), false);
452  hit.click = () => activate(hit);
453  F.key(f.q, { key: 'Enter', preventDefault() {} });
454  assert.equal(assigned.at(-1), hit.dataset.href);
455  activate(hit);
456  assert.equal(assigned.at(-1), hit.dataset.href);
457  assert.notEqual(doc.getElementById('finder-status').textContent, '');
458  f.q.value = '';
459  F.input(f.q);
460  assert.equal(doc.getElementById('finder-status').textContent, '');
461});

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