v0.29.0 55fcc8f

tree.test.mjs RawHistoryFolder

1// Tests of the tree rows of static/js/files.js (window.ermiteTree) on a tiny
2// hand-rolled DOM. Run with
3//   node --test 'internal/adapters/web/jstest/*.test.mjs'
4import assert from 'node:assert/strict';
5import { test } from 'node:test';
6import { dom, El, el, installDom } from './dom.mjs';
7
8// A row is a name: a trailing "/" makes a directory, "!x" a div that is not a
9// row, "more" the tree-more row of a truncated directory.
10function row(name, depth) {
11  const dir = name.endsWith('/');
12  const li = el('li', { cls: name === 'more' ? 'tree-more' : '', attrs: { 'data-depth': depth } });
13  li.name = name;
14  if (dir) {
15    li.setAttribute('data-expand', '/rows/' + name);
16    li.setAttribute('aria-expanded', 'false');
17  }
18  li.setAttribute('role', 'treeitem');
19  const main = el('span', { cls: 't-main' }, el('span', { cls: 't-node' }));
20  const a = el('a', { cls: 't-name', href: (dir ? '/tree/' : '/blob/') + name });
21  a.click = () => { globalThis.location.href = a.href; };
22  main.append(a);
23  li.append(main);
24  li.append(el('span', { cls: 't-sub' }, el('a', { cls: 't-commit', href: '/commit/' + name })));
25  return li;
26}
27
28installDom({
29  parseHTML: (text) => text.split(',').filter(Boolean).map((n) => (n[0] === '!' ? new El('div') : row(n, 0))),
30});
31await import('../static/js/files.js');
32const T = window.ermiteTree;
33
34// fetches: URL -> resolve function, to answer a request when the test says so.
35const pending = new Map();
36globalThis.fetch = (url) => new Promise((resolve) => {
37  pending.set(url, (text, ok = true) => resolve({ ok, status: ok ? 200 : 500, text: async () => text }));
38});
39const answer = async (url, text, ok) => {
40  pending.get(url)(text, ok);
41  await new Promise((r) => setTimeout(r, 0));
42};
43
44// click sends a click on the link of tr; it answers whether the page's own
45// navigation was kept (no preventDefault).
46const click = (tr, mods = {}, target = tr.querySelector('a.t-name')) => {
47  let prevented = false;
48  T.click({ target, button: 0, preventDefault() { prevented = true; }, ...mods });
49  return !prevented;
50};
51const isOpen = (tr) => tr.getAttribute('aria-expanded') === 'true';
52
53// table builds the page: the list of rows, with the cursor on the first.
54function table(...names) {
55  const ul = el('ul', { id: 'tree-rows', attrs: { 'data-rows': '' } }, ...names.map((n) => row(n, 0)));
56  dom.body.replaceChildren(ul);
57  ul.children[0]?.setAttribute('data-cursor', '');
58  T.init();
59  dom.active = null;
60  return ul;
61}
62const byName = (body, n) => body.children.find((r) => r.name === n);
63const shown = (body) => body.children.filter((r) => !r.hidden).map((r) => r.name);
64
65test('a directory opens in place and closes again without a request', async () => {
66  const body = table('P/', 'f');
67  click(body.children[0]);
68  assert.equal(body.children[0].getAttribute('aria-busy'), 'true');
69  await answer('/rows/P/', 'a,b');
70  assert.deepEqual(shown(body), ['P/', 'a', 'b', 'f']);
71  assert.equal(byName(body, 'a').dataset.depth, '1');
72  assert.ok(isOpen(body.children[0]));
73  click(body.children[0]);
74  assert.deepEqual(shown(body), ['P/', 'f']);
75  pending.clear();
76  click(body.children[0]);
77  assert.deepEqual(shown(body), ['P/', 'a', 'b', 'f']);
78  assert.equal(pending.size, 0, 'no new request');
79});
80
81test('TS-001/TS-002: a directory that loads under a closed ancestor comes in hidden', async () => {
82  pending.clear();
83  const body = table('P/', 'f');
84  const P = body.children[0];
85  click(P);
86  await answer('/rows/P/', 'C/,x');
87  const C = byName(body, 'C/');
88  click(C); // C starts loading
89  assert.equal(C.getAttribute('aria-busy'), 'true');
90  C.setAttribute('data-cursor', '');
91  click(P); // P closes meanwhile
92  assert.deepEqual(shown(body), ['P/', 'f']);
93  assert.ok(P.hasAttribute('data-cursor'), 'the cursor of a hidden row moves to the closed row');
94  assert.ok(!C.hasAttribute('data-cursor'));
95
96  await answer('/rows/C/', 'c1,c2');
97  assert.deepEqual(shown(body), ['P/', 'f'], 'no visible row under a closed directory');
98  assert.deepEqual(body.children.map((r) => r.name), ['P/', 'C/', 'c1', 'c2', 'x', 'f']);
99  assert.equal(byName(body, 'c1').hidden, true);
100  assert.equal(C.getAttribute('aria-busy'), null);
101  assert.ok(isOpen(C), 'C is open and loaded');
102  assert.ok(isOpen(C));
103
104  pending.clear();
105  click(P); // P opens: C's rows show with it
106  assert.deepEqual(shown(body), ['P/', 'C/', 'c1', 'c2', 'x', 'f']);
107  assert.equal(pending.size, 0, 'C was not fetched again');
108});
109
110test('a closed directory under a closed ancestor stays closed when the ancestor opens', async () => {
111  pending.clear();
112  const body = table('P/');
113  click(body.children[0]);
114  await answer('/rows/P/', 'C/');
115  const C = byName(body, 'C/');
116  click(C);
117  await answer('/rows/C/', 'c1');
118  click(C); // C closed
119  click(body.children[0]);
120  click(body.children[0]);
121  assert.deepEqual(shown(body), ['P/', 'C/']);
122});
123
124test('TS-006: only rows are kept; no row falls back to navigating', async () => {
125  pending.clear();
126  const body = table('P/');
127  click(body.children[0]);
128  await answer('/rows/P/', '!junk,a');
129  assert.deepEqual(body.children.map((r) => r.name), ['P/', 'a']);
130
131  const other = table('Q/');
132  globalThis.location.href = '';
133  click(other.children[0]);
134  await answer('/rows/Q/', '!junk');
135  assert.equal(globalThis.location.href, '/tree/Q/');
136  assert.deepEqual(other.children.map((r) => r.name), ['Q/']);
137});
138
139test('a modified or non-left click keeps the link navigation', () => {
140  pending.clear();
141  const cases = [
142    { ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }, { button: 2 },
143  ];
144  for (const mods of cases) {
145    const body = table('P/');
146    assert.equal(click(body.children[0], mods), true, JSON.stringify(mods) + ' is not intercepted');
147    assert.equal(pending.size, 0, 'no request');
148    assert.equal(body.children[0].getAttribute('aria-busy'), null);
149  }
150  assert.equal(click(table('P/').children[0]), false, 'a plain left click is intercepted');
151});
152
153test('a second click while the rows are loading is ignored', async () => {
154  pending.clear();
155  const body = table('P/');
156  let requests = 0;
157  const fetch0 = globalThis.fetch;
158  globalThis.fetch = (url) => { requests++; return fetch0(url); };
159  click(body.children[0]);
160  click(body.children[0]);
161  assert.equal(requests, 1);
162  await answer('/rows/P/', 'a');
163  globalThis.fetch = fetch0;
164  assert.deepEqual(shown(body), ['P/', 'a']);
165});
166
167test('a failed request navigates to the directory', async () => {
168  pending.clear();
169  const body = table('P/');
170  globalThis.location.href = '';
171  click(body.children[0]);
172  await answer('/rows/P/', 'oops', false);
173  assert.equal(globalThis.location.href, '/tree/P/');
174  assert.deepEqual(body.children.map((r) => r.name), ['P/']);
175});
176
177test('the tree-more row has a depth and follows its directory', async () => {
178  pending.clear();
179  const body = table('P/', 'f');
180  click(body.children[0]);
181  await answer('/rows/P/', 'a,more');
182  const more = byName(body, 'more');
183  assert.equal(more.dataset.depth, '1');
184  assert.equal(more.style.props['--depth'], '1');
185  click(body.children[0]);
186  assert.equal(more.hidden, true, 'hidden with its directory');
187  assert.deepEqual(shown(body), ['P/', 'f']);
188  click(body.children[0]);
189  assert.equal(more.hidden, false, 'shown with it again');
190});
191
192// ---- the keyboard: l, h and Enter on the row under the cursor ----------------
193
194const cursorName = (body) => body.children.find((r) => r.hasAttribute('data-cursor'))?.name;
195const nav = () => globalThis.location.href;
196
197test('l opens a closed directory, then enters it; h closes it, or goes to its directory', async () => {
198  pending.clear();
199  const body = table('P/', 'f');
200  assert.equal(T.key(true), true);
201  await answer('/rows/P/', 'C/,a');
202  assert.deepEqual(shown(body), ['P/', 'C/', 'a', 'f']);
203  assert.equal(cursorName(body), 'P/', 'the cursor stays on the directory it opened');
204  T.key(true); // open: into its first entry
205  assert.equal(cursorName(body), 'C/');
206  T.key(false); // C/ is closed: go to its directory
207  assert.equal(cursorName(body), 'P/');
208  T.key(false); // P/ is open: close it
209  assert.deepEqual(shown(body), ['P/', 'f']);
210  assert.ok(!isOpen(body.children[0]));
211  assert.equal(cursorName(body), 'P/');
212});
213
214test('l on a file opens it; Enter opens a file and toggles a directory', async () => {
215  pending.clear();
216  const body = table('f', 'P/');
217  globalThis.location.href = '';
218  assert.equal(T.key(true), true);
219  assert.equal(nav(), '/blob/f');
220  body.children[0].removeAttribute('data-cursor');
221  body.children[1].setAttribute('data-cursor', '');
222  assert.equal(T.open(), true);
223  await answer('/rows/P/', 'x');
224  assert.deepEqual(shown(body), ['f', 'P/', 'x']);
225  assert.equal(T.open(), true);
226  assert.deepEqual(shown(body), ['f', 'P/']);
227});
228
229test('h on a top-level row goes to the parent page, when there is one', async () => {
230  pending.clear();
231  const body = table('f');
232  globalThis.location.href = '';
233  T.key(false);
234  assert.equal(nav(), '', 'at the root there is nowhere to go');
235  body.setAttribute('data-up', '/tree/main');
236  T.key(false);
237  assert.equal(nav(), '/tree/main');
238});
239
240test('the keys are not the tree\'s while the finder shows its results', async () => {
241  pending.clear();
242  const body = table('P/', 'f');
243  body.removeAttribute('data-rows');
244  assert.equal(T.active(), false);
245  assert.equal(T.key(true), false);
246  assert.equal(T.open(), false);
247  assert.deepEqual(pending.size, 0);
248});
249
250test('a click on the name puts the cursor on the row', async () => {
251  pending.clear();
252  const body = table('a', 'b');
253  click(body.children[1], {}, body.children[1].querySelector('a.t-name'));
254  assert.equal(cursorName(body), 'b');
255  assert.equal(body.children[0].hasAttribute('data-cursor'), false);
256  // the click on the last commit is the link's own, and the row takes the cursor
257  let prevented = false;
258  const sub = body.children[0].querySelector('a.t-commit');
259  T.click({ target: sub, button: 0, preventDefault() { prevented = true; } });
260  assert.equal(prevented, false);
261  assert.equal(cursorName(body), 'a', 'the cursor follows the click on the row, whatever the target');
262});
263
264// ---- focus: one model, a roving tabindex on the rows ---------------------------
265
266const stops = (body) => body.children.filter((r) => r.getAttribute('tabindex') === '0').map((r) => r.name);
267const subTab = (r) => r.querySelector('a.t-commit').getAttribute('tabindex');
268
269test('init: one tab stop, the row under the cursor; name links are out of the tab order', () => {
270  const body = table('a', 'P/', 'b');
271  assert.deepEqual(stops(body), ['a']);
272  for (const r of body.children) {
273    assert.equal(r.querySelector('a.t-name').getAttribute('tabindex'), '-1', 'the row stands for its name');
274    assert.equal(subTab(r), r.name === 'a' ? '0' : '-1', 'the commit link follows its row');
275  }
276  assert.equal(body.children[1].getAttribute('tabindex'), '-1');
277});
278
279test('l or h before any focus in the tree brings the focus to the cursor row', async () => {
280  pending.clear();
281  const body = table('P/', 'f');
282  assert.equal(dom.active, null, 'the page has the focus after a load');
283  T.key(true); // opens P/, the cursor stays on it
284  assert.equal(dom.active, body.children[0]);
285  await answer('/rows/P/', 'a');
286  dom.active = null;
287  T.key(false); // closes P/
288  assert.equal(dom.active, body.children[0]);
289});
290
291test('l and h move the focus with the cursor, and the tab stop with them', async () => {
292  pending.clear();
293  const body = table('P/', 'f');
294  T.key(true);
295  await answer('/rows/P/', 'C/,a');
296  const C = byName(body, 'C/');
297  assert.equal(C.getAttribute('tabindex'), '-1', 'rows that come in are out of the tab order');
298  assert.equal(C.querySelector('a.t-name').getAttribute('tabindex'), '-1');
299  assert.equal(subTab(C), '-1');
300  T.key(true); // into the first entry
301  assert.equal(dom.active, C);
302  assert.deepEqual(stops(body), ['C/']);
303  assert.equal(subTab(C), '0');
304  assert.equal(subTab(body.children[0]), '-1');
305  T.key(false); // up to P/
306  assert.equal(dom.active, body.children[0]);
307  assert.deepEqual(stops(body), ['P/']);
308});
309
310test('sync (j k gg G of ermite.js) puts the focus and the tab stop on the row', () => {
311  const body = table('a', 'b', 'c');
312  body.children[0].removeAttribute('data-cursor');
313  body.children[2].setAttribute('data-cursor', ''); // ermite.js moved the cursor
314  T.sync(body.children[2]);
315  assert.equal(dom.active, body.children[2]);
316  assert.deepEqual(stops(body), ['c']);
317  assert.equal(body.children.filter((r) => r.hasAttribute('data-cursor')).length, 1);
318  T.sync(el('li')); // a row of another list: not the tree's
319  assert.equal(dom.active, body.children[2]);
320});
321
322test('a click puts the focus and the tab stop on its row, directory or not', async () => {
323  pending.clear();
324  const body = table('a', 'b/');
325  click(body.children[1]);
326  assert.equal(dom.active, body.children[1]);
327  assert.deepEqual(stops(body), ['b/']);
328  await answer('/rows/b/', 'x');
329  // a click on the commit link: the row takes the focus, the link keeps its own navigation
330  let prevented = false;
331  T.click({ target: body.children[0].querySelector('a.t-commit'), button: 0, preventDefault() { prevented = true; } });
332  assert.equal(prevented, false);
333  assert.equal(dom.active, body.children[0]);
334  assert.deepEqual(stops(body), ['a']);
335  // a modified click on the name too
336  click(body.children[1], { ctrlKey: true });
337  assert.equal(dom.active, body.children[1]);
338});
339
340test('collapsing a branch with the focus or the cursor in it moves both to the directory', async () => {
341  pending.clear();
342  const body = table('P/', 'f');
343  T.key(true);
344  await answer('/rows/P/', 'a,b');
345  const a = byName(body, 'a');
346  click(a); // the cursor and the focus on a
347  assert.equal(dom.active, a);
348  click(body.children[0]); // the directory closes
349  assert.deepEqual(shown(body), ['P/', 'f']);
350  assert.equal(dom.active, body.children[0], 'the focus leaves the hidden row');
351  assert.deepEqual(stops(body), ['P/']);
352  assert.ok(body.children[0].hasAttribute('data-cursor'));
353  assert.equal(a.hasAttribute('data-cursor'), false);
354  assert.equal(a.getAttribute('tabindex'), '-1');
355});
356
357test('collapsing a directory the focus is not in leaves the focus alone', async () => {
358  pending.clear();
359  const body = table('P/', 'f');
360  T.key(true);
361  await answer('/rows/P/', 'a');
362  click(body.children[0]); // P/ has the cursor and the focus; closing it keeps them
363  assert.equal(dom.active, body.children[0]);
364  click(body.children[3] ?? body.children[2]); // f
365  const f = byName(body, 'f');
366  assert.equal(dom.active, f);
367  T.key(false); // f: no parent in the page, no up: nothing
368  assert.equal(dom.active, f);
369});
370
371test('Enter acts on the row that has the cursor, which is the focused row', async () => {
372  pending.clear();
373  const body = table('a', 'P/');
374  T.sync(body.children[1]); // ermite.js moved the cursor and the focus to P/
375  assert.equal(dom.active, body.children[1]);
376  T.open();
377  await answer('/rows/P/', 'x');
378  assert.ok(isOpen(body.children[1]));
379  assert.equal(body.children.filter((r) => r.hasAttribute('data-cursor')).length, 1);
380});
381
382// ---- ARIA: the level is set on the rows inserted; position and size come with them ----
383
384test('inserted rows get their level; the server gave them posinset and setsize', async () => {
385  pending.clear();
386  const body = table('P/', 'f');
387  T.key(true);
388  await answer('/rows/P/', 'C/,a');
389  assert.equal(byName(body, 'C/').getAttribute('aria-level'), '2');
390  assert.equal(byName(body, 'C/').getAttribute('role'), 'treeitem');
391  // a directory inside it: level 3
392  click(byName(body, 'C/'));
393  await answer('/rows/C/', 'z');
394  assert.equal(byName(body, 'z').getAttribute('aria-level'), '3');
395});
396
397// ---- the arrow keys: the APG tree pattern, apart from the Vim keys ---------------
398
399test('Down, Up, Home and End walk the visible rows, with the focus', async () => {
400  pending.clear();
401  const body = table('P/', 'f', 'g');
402  T.arrow('ArrowRight');
403  await answer('/rows/P/', 'a,b');
404  T.arrow('ArrowRight'); // open: into the first entry
405  assert.equal(cursorName(body), 'a');
406  T.arrow('ArrowDown');
407  assert.equal(cursorName(body), 'b');
408  assert.equal(dom.active, byName(body, 'b'));
409  T.arrow('ArrowUp');
410  assert.equal(cursorName(body), 'a');
411  T.arrow('End');
412  assert.equal(cursorName(body), 'g');
413  T.arrow('ArrowDown'); // the last row: stays
414  assert.equal(cursorName(body), 'g');
415  T.arrow('Home');
416  assert.equal(cursorName(body), 'P/');
417  T.arrow('ArrowUp');
418  assert.equal(cursorName(body), 'P/');
419  // a collapsed branch is skipped
420  T.arrow('ArrowLeft');
421  T.arrow('ArrowDown');
422  assert.equal(cursorName(body), 'f');
423  assert.deepEqual(stops(body), ['f']);
424});
425
426test('Right opens a closed directory, enters an open one, and does nothing on a file', async () => {
427  pending.clear();
428  globalThis.location.href = '';
429  const body = table('P/', 'f');
430  assert.equal(T.arrow('ArrowRight'), true);
431  await answer('/rows/P/', 'a');
432  assert.ok(isOpen(body.children[0]));
433  assert.equal(cursorName(body), 'P/', 'opening keeps the cursor on the directory');
434  T.arrow('ArrowRight');
435  assert.equal(cursorName(body), 'a', 'on an open directory: the first entry');
436  T.arrow('ArrowRight'); // a file: nothing, in particular it is not opened
437  assert.equal(cursorName(body), 'a');
438  assert.equal(nav(), '', 'a file is not opened by the arrow');
439  assert.equal(pending.size, 1, 'no further request');
440});
441
442test('Left closes an open directory, goes to the parent, and does nothing at the top', async () => {
443  pending.clear();
444  globalThis.location.href = '';
445  const body = table('P/', 'f');
446  body.setAttribute('data-up', '/tree/main'); // a page to go up to: not for an arrow
447  T.arrow('ArrowRight');
448  await answer('/rows/P/', 'a');
449  T.arrow('ArrowRight');
450  T.arrow('ArrowLeft'); // a: to its directory
451  assert.equal(cursorName(body), 'P/');
452  T.arrow('ArrowLeft'); // P/ is open: closes
453  assert.deepEqual(shown(body), ['P/', 'f']);
454  T.arrow('ArrowLeft'); // closed and top level: nothing
455  assert.equal(cursorName(body), 'P/');
456  assert.equal(nav(), '', 'the arrow does not leave the page; h does');
457  T.key(false);
458  assert.equal(nav(), '/tree/main');
459});
460
461test('l opens a file where Right does not', () => {
462  pending.clear();
463  globalThis.location.href = '';
464  const body = table('f');
465  T.arrow('ArrowRight');
466  assert.equal(nav(), '');
467  T.key(true);
468  assert.equal(nav(), '/blob/f');
469  assert.ok(body);
470});
471
472test('the tree takes no arrow key while the finder shows its results, nor any other key', () => {
473  const body = table('a', 'b');
474  body.removeAttribute('data-rows');
475  assert.equal(T.arrow('ArrowDown'), false);
476  assert.equal(cursorName(body), 'a');
477  body.setAttribute('data-rows', '');
478  assert.equal(T.arrow('x'), false);
479  assert.equal(T.arrow('PageDown'), false);
480});

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