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});