v0.8.0 eaf4295

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 { El, el, installDom } from './dom.mjs';
7
8// A row is a name: a trailing "/" makes a directory with a link, "!x" a div
9// that is not a row, "more" the tree-more row of a truncated directory.
10function row(name, depth) {
11  const tr = el('tr', { cls: name === 'more' ? 'tree-more' : '', attrs: { 'data-depth': depth } });
12  tr.name = name;
13  tr.append(el('td', { cls: 'c-kind', text: '▸' }));
14  if (name.endsWith('/')) {
15    tr.append(el('a', { href: '/tree/' + name, attrs: { 'data-expand': '/rows/' + name, 'aria-expanded': 'false' } }));
16  }
17  return tr;
18}
19
20installDom({
21  parseHTML: (text) => text.split(',').filter(Boolean).map((n) => (n[0] === '!' ? new El('div') : row(n, 0))),
22});
23await import('../static/js/files.js');
24const T = window.ermiteTree;
25
26// fetches: URL -> resolve function, to answer a request when the test says so.
27const pending = new Map();
28globalThis.fetch = (url) => new Promise((resolve) => {
29  pending.set(url, (text, ok = true) => resolve({ ok, status: ok ? 200 : 500, text: async () => text }));
30});
31const answer = async (url, text, ok) => {
32  pending.get(url)(text, ok);
33  await new Promise((r) => setTimeout(r, 0));
34};
35
36// click sends a click on the link of tr; it answers whether the page's own
37// navigation was kept (no preventDefault).
38const click = (tr, mods = {}) => {
39  const a = tr.querySelector('a[data-expand]');
40  let prevented = false;
41  T.click({ target: a, button: 0, preventDefault() { prevented = true; }, ...mods });
42  return !prevented;
43};
44const kindOf = (tr) => tr.querySelector('.c-kind').textContent;
45
46function table(...names) {
47  return el('tbody', {}, ...names.map((n) => row(n, 0)));
48}
49const byName = (body, n) => body.children.find((r) => r.name === n);
50const shown = (body) => body.children.filter((r) => !r.hidden).map((r) => r.name);
51
52test('a directory opens in place and closes again without a request', async () => {
53  const body = table('P/', 'f');
54  click(body.children[0]);
55  assert.equal(body.children[0].getAttribute('aria-busy'), 'true');
56  await answer('/rows/P/', 'a,b');
57  assert.deepEqual(shown(body), ['P/', 'a', 'b', 'f']);
58  assert.equal(byName(body, 'a').dataset.depth, '1');
59  assert.equal(kindOf(body.children[0]), '▾');
60  click(body.children[0]);
61  assert.deepEqual(shown(body), ['P/', 'f']);
62  pending.clear();
63  click(body.children[0]);
64  assert.deepEqual(shown(body), ['P/', 'a', 'b', 'f']);
65  assert.equal(pending.size, 0, 'no new request');
66});
67
68test('TS-001/TS-002: a directory that loads under a closed ancestor comes in hidden', async () => {
69  pending.clear();
70  const body = table('P/', 'f');
71  const P = body.children[0];
72  click(P);
73  await answer('/rows/P/', 'C/,x');
74  const C = byName(body, 'C/');
75  click(C); // C starts loading
76  assert.equal(C.getAttribute('aria-busy'), 'true');
77  C.setAttribute('data-cursor', '');
78  click(P); // P closes meanwhile
79  assert.deepEqual(shown(body), ['P/', 'f']);
80  assert.ok(P.hasAttribute('data-cursor'), 'the cursor of a hidden row moves to the closed row');
81  assert.ok(!C.hasAttribute('data-cursor'));
82
83  await answer('/rows/C/', 'c1,c2');
84  assert.deepEqual(shown(body), ['P/', 'f'], 'no visible row under a closed directory');
85  assert.deepEqual(body.children.map((r) => r.name), ['P/', 'C/', 'c1', 'c2', 'x', 'f']);
86  assert.equal(byName(body, 'c1').hidden, true);
87  assert.equal(C.getAttribute('aria-busy'), null);
88  assert.equal(C.querySelector('a[data-expand]').getAttribute('aria-expanded'), 'true', 'C is open and loaded');
89  assert.equal(kindOf(C), '▾');
90
91  pending.clear();
92  click(P); // P opens: C's rows show with it
93  assert.deepEqual(shown(body), ['P/', 'C/', 'c1', 'c2', 'x', 'f']);
94  assert.equal(pending.size, 0, 'C was not fetched again');
95});
96
97test('a closed directory under a closed ancestor stays closed when the ancestor opens', async () => {
98  pending.clear();
99  const body = table('P/');
100  click(body.children[0]);
101  await answer('/rows/P/', 'C/');
102  const C = byName(body, 'C/');
103  click(C);
104  await answer('/rows/C/', 'c1');
105  click(C); // C closed
106  click(body.children[0]);
107  click(body.children[0]);
108  assert.deepEqual(shown(body), ['P/', 'C/']);
109});
110
111test('TS-006: only rows are kept; no row falls back to navigating', async () => {
112  pending.clear();
113  const body = table('P/');
114  click(body.children[0]);
115  await answer('/rows/P/', '!junk,a');
116  assert.deepEqual(body.children.map((r) => r.name), ['P/', 'a']);
117
118  const other = table('Q/');
119  globalThis.location.href = '';
120  click(other.children[0]);
121  await answer('/rows/Q/', '!junk');
122  assert.equal(globalThis.location.href, '/tree/Q/');
123  assert.deepEqual(other.children.map((r) => r.name), ['Q/']);
124});
125
126test('a modified or non-left click keeps the link navigation', () => {
127  pending.clear();
128  const cases = [
129    { ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }, { button: 2 },
130  ];
131  for (const mods of cases) {
132    const body = table('P/');
133    assert.equal(click(body.children[0], mods), true, JSON.stringify(mods) + ' is not intercepted');
134    assert.equal(pending.size, 0, 'no request');
135    assert.equal(body.children[0].getAttribute('aria-busy'), null);
136  }
137  assert.equal(click(table('P/').children[0]), false, 'a plain left click is intercepted');
138});
139
140test('a second click while the rows are loading is ignored', async () => {
141  pending.clear();
142  const body = table('P/');
143  let requests = 0;
144  const fetch0 = globalThis.fetch;
145  globalThis.fetch = (url) => { requests++; return fetch0(url); };
146  click(body.children[0]);
147  click(body.children[0]);
148  assert.equal(requests, 1);
149  await answer('/rows/P/', 'a');
150  globalThis.fetch = fetch0;
151  assert.deepEqual(shown(body), ['P/', 'a']);
152});
153
154test('a failed request navigates to the directory', async () => {
155  pending.clear();
156  const body = table('P/');
157  globalThis.location.href = '';
158  click(body.children[0]);
159  await answer('/rows/P/', 'oops', false);
160  assert.equal(globalThis.location.href, '/tree/P/');
161  assert.deepEqual(body.children.map((r) => r.name), ['P/']);
162});
163
164test('the tree-more row has a depth and follows its directory', async () => {
165  pending.clear();
166  const body = table('P/', 'f');
167  click(body.children[0]);
168  await answer('/rows/P/', 'a,more');
169  const more = byName(body, 'more');
170  assert.equal(more.dataset.depth, '1');
171  assert.equal(more.style.props['--depth'], '1');
172  click(body.children[0]);
173  assert.equal(more.hidden, true, 'hidden with its directory');
174  assert.deepEqual(shown(body), ['P/', 'f']);
175  click(body.children[0]);
176  assert.equal(more.hidden, false, 'shown with it again');
177});

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