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