1// Tests of the DOM glue of the file finder and of the ":" palette
2// (static/js/files.js) on the small DOM of dom.mjs. 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, flushFrames, installDom, settle } from './dom.mjs';
7
8installDom();
9await import('../static/js/files.js');
10const F = window.ermiteFinder;
11
12const PATHS = [
13 'README.md',
14 'cmd/ermite/main.go',
15 'internal/adapters/web/tree.go',
16 'internal/adapters/web/tree.templ',
17 'docs/web/tree-view.md',
18 'src/treehouse.txt',
19];
20for (let i = 0; i < 1194; i++) PATHS.push('gen/f' + i + '.dat'); // 1,200 in all
21
22// The path list answers when the test says so (once: the finder keeps it).
23let release;
24const gate = new Promise((r) => { release = r; });
25let fetches = 0;
26globalThis.fetch = async () => {
27 fetches++;
28 await gate;
29 return { ok: true, json: async () => ({ paths: PATHS, truncated: true }) };
30};
31
32// page builds the parts of the Files view and of the palette that files.js
33// reaches, around a query field and a tree of three rows (li).
34function page() {
35 const rows = ['r1', 'r2', 'r3'].map((n) => el('li', { attrs: { 'data-nm': n } }));
36 rows[1].setAttribute('data-cursor', '');
37 const q = el('input', { id: 'finder-q' });
38 const cmdQ = el('input', { id: 'cmd-q' });
39 const treeRows = el('ul', { id: 'tree-rows', attrs: { 'data-rows': '' } }, ...rows);
40 const finderList = el('ul', { id: 'finder-list' });
41 const cmdList = el('ul', { id: 'cmd-list' }, el('li', { attrs: { 'data-k': 'section' }, cls: 'entry' }));
42 const finder = el('div', { id: 'finder', hidden: true }, el('p', { id: 'finder-status' }), finderList);
43 const treeView = el('div', { id: 'tree-view' }, treeRows);
44 dom.body.replaceChildren(q, cmdQ, treeView, finder, cmdList);
45 dom.body.setAttribute('data-paths', '/paths.json');
46 dom.body.setAttribute('data-blob', '/blob/main');
47 dom.clicked = [];
48 return { rows, q, cmdQ, treeRows, finderList, cmdList, finder, treeView };
49}
50
51const key = (k, extra = {}) => {
52 const e = { key: k, isComposing: false, prevented: false, preventDefault() { this.prevented = true; }, stopPropagation() {}, ...extra };
53 return e;
54};
55const status = () => document.getElementById('finder-status').textContent;
56const hrefs = (list) => list.children.map((li) => li.querySelector('a').dataset.href);
57
58test('Enter before the results opens the top hit once they come', async () => {
59 const p = page();
60 p.q.value = 'tree';
61 F.input(p.q);
62 assert.equal(p.finder.hidden, false, 'the results replace the tree at once');
63 F.key(p.q, key('Enter'));
64 assert.equal(dom.clicked.length, 0, 'nothing to open yet');
65 release();
66 await settle();
67 assert.equal(p.finderList.children.length > 0, true);
68 assert.equal(dom.clicked.length, 1);
69 assert.equal(dom.clicked[0].dataset.href, hrefs(p.finderList)[0]);
70 assert.match(dom.clicked[0].dataset.href, /^\/blob\/main\/.*tree/);
71 assert.equal(fetches, 1);
72});
73
74test('the status counts the files and names the real size of a truncated list', async () => {
75 const p = page();
76 p.q.value = 'tree';
77 F.input(p.q);
78 await settle();
79 assert.equal(status(), '4 files match · only the first 1,200 files are searched');
80 p.q.value = 'zzzz';
81 F.input(p.q);
82 await settle();
83 assert.match(status(), /^no file matches · /);
84});
85
86test('an Enter is dropped when the field changed since', async () => {
87 const p = page();
88 p.q.value = 'tree';
89 F.input(p.q);
90 F.key(p.q, key('Enter')); // pending on "tree"
91 p.q.value = 'tre';
92 F.input(p.q);
93 await settle();
94 assert.equal(p.finderList.children.length > 0, true, 'the results of "tre" are shown');
95 assert.equal(dom.clicked.length, 0);
96});
97
98test('Enter on current results opens the cursor row, whichever way the cursor moved', async () => {
99 const p = page();
100 p.q.value = 'tree';
101 F.input(p.q);
102 await settle();
103 const items = p.finderList.children;
104 assert.equal(items[0].getAttribute('aria-selected'), 'true');
105 assert.equal(p.q.getAttribute('aria-activedescendant'), items[0].id);
106 // j/k (keys.templ) move data-cursor while the field is blurred.
107 items[0].removeAttribute('data-cursor');
108 items[2].setAttribute('data-cursor', '');
109 const e = key('Enter');
110 F.key(p.q, e);
111 assert.equal(e.prevented, true);
112 assert.equal(items[2].getAttribute('aria-selected'), 'true');
113 assert.equal(items[0].getAttribute('aria-selected'), 'false');
114 assert.equal(p.q.getAttribute('aria-activedescendant'), items[2].id);
115 assert.equal(dom.clicked[0].dataset.href, items[2].querySelector('a').dataset.href);
116 // The arrows move from there.
117 F.key(p.q, key('ArrowUp'));
118 assert.equal(p.q.getAttribute('aria-activedescendant'), items[1].id);
119 assert.ok(items[1].hasAttribute('data-cursor') && !items[2].hasAttribute('data-cursor'));
120});
121
122test('an input method composing does not drive the finder', async () => {
123 const p = page();
124 p.q.value = 'tree';
125 F.input(p.q);
126 await settle();
127 for (const k of ['Enter', 'Escape', 'ArrowDown']) {
128 const e = key(k, { isComposing: true });
129 F.key(p.q, e);
130 assert.equal(e.prevented, false, k);
131 }
132 assert.equal(p.q.value, 'tree');
133 assert.equal(dom.clicked.length, 0);
134});
135
136test('Escape clears the field and puts the cursor back on the tree', async () => {
137 const cases = [
138 { name: 'the row that had it', hide: false, want: 'r2' },
139 { name: 'the first visible row when it is hidden', hide: true, want: 'r1' },
140 ];
141 for (const c of cases) {
142 const p = page();
143 p.q.value = 'tree';
144 F.input(p.q);
145 await settle();
146 assert.equal(p.treeView.hidden, true);
147 assert.ok(!p.rows[1].hasAttribute('data-cursor'), 'the tree gives up its cursor to the results');
148 assert.ok(p.finderList.hasAttribute('data-rows'));
149 if (c.hide) p.rows[1].hidden = true;
150 F.key(p.q, key('Escape'));
151 assert.equal(p.q.value, '', c.name);
152 assert.equal(p.finder.hidden, true);
153 assert.equal(p.treeView.hidden, false);
154 assert.equal(p.finderList.children.length, 0);
155 const got = p.rows.filter((r) => r.hasAttribute('data-cursor')).map((r) => r.getAttribute('data-nm'));
156 assert.deepEqual(got, [c.want], c.name);
157 assert.ok(p.treeRows.hasAttribute('data-rows') && !p.finderList.hasAttribute('data-rows'));
158 }
159});
160
161test('label marks a surrogate pair whole', () => {
162 for (const pos of [[2], [3], [2, 3]]) {
163 const lbl = F.label('a/😀b', pos);
164 const marks = lbl.querySelectorAll('mark');
165 assert.deepEqual(marks.map((m) => m.textContent), ['😀'], JSON.stringify(pos));
166 assert.equal(lbl.textContent, 'a/😀b');
167 }
168 const lbl = F.label('a/😀b', [4]);
169 assert.deepEqual(lbl.querySelectorAll('mark').map((m) => m.textContent), ['b']);
170 assert.equal(F.label('x\u202ey', [0]).querySelectorAll('mark').length, 0, 'no highlight on a marked path');
171 assert.equal(F.label('x\u202ey', [0]).textContent, 'x<U+202E>y');
172});
173
174test('the palette lists the file hits under its entries', async () => {
175 const p = page();
176 p.cmdQ.value = 'tree';
177 F.palette(p.cmdQ);
178 await settle();
179 const hits = p.cmdList.children.filter((li) => li.className === 'finder-hit');
180 assert.equal(hits.length, 4);
181 assert.equal(p.cmdList.children[0].className, 'entry', 'the entries stay first');
182 assert.deepEqual(hits.map((li) => li.querySelector('a').dataset.href).sort(), [
183 '/blob/main/docs/web/tree-view.md',
184 '/blob/main/internal/adapters/web/tree.go',
185 '/blob/main/internal/adapters/web/tree.templ',
186 '/blob/main/src/treehouse.txt',
187 ]);
188 assert.equal(hits[0].querySelector('a').hasAttribute('href'), false);
189 assert.equal(hits[0].querySelector('a').hasAttribute('tabindex'), false);
190 assert.equal(hits[0].querySelector('.k').textContent, 'file');
191 assert.equal(p.cmdList.children.filter((li) => li.hasAttribute('data-cursor')).length, 1, 'one cursor');
192
193 // A longer query narrows the hits; an emptied field removes them.
194 p.cmdQ.value = 'treeh';
195 F.palette(p.cmdQ);
196 await settle();
197 assert.deepEqual(hrefs({ children: p.cmdList.children.filter((li) => li.className === 'finder-hit') }), ['/blob/main/src/treehouse.txt']);
198 p.cmdQ.value = '';
199 F.palette(p.cmdQ);
200 assert.equal(p.cmdList.children.length, 1);
201});
202
203test('Enter typed before the frame of the query runs opens the top hit', async () => {
204 const p = page();
205 release();
206 await F.load(); // the path list is loaded: only the frame is pending
207 dom.holdFrames = true;
208 try {
209 p.q.value = 'maingo';
210 F.input(p.q);
211 const e = key('Enter');
212 F.key(p.q, e);
213 assert.equal(e.prevented, true);
214 assert.equal(dom.clicked.length, 0, 'nothing to open before the frame');
215 flushFrames();
216 await settle();
217 assert.deepEqual(dom.clicked.map((a) => a.dataset.href), ['/blob/main/cmd/ermite/main.go']);
218 } finally {
219 dom.holdFrames = false;
220 }
221});
222
223test('the palette keeps one search per frame and drops a search when it closes', async () => {
224 const p = page();
225 release();
226 await F.load();
227 dom.holdFrames = true;
228 try {
229 for (const q of ['t', 'tr', 'tre', 'tree']) {
230 p.cmdQ.value = q;
231 F.palette(p.cmdQ);
232 }
233 assert.equal(dom.frames.size, 1, 'the keystrokes of a frame share one search');
234 flushFrames();
235 await settle();
236 assert.equal(p.cmdList.children.filter((li) => li.className === 'finder-hit').length, 4);
237
238 p.cmdQ.value = 'readme';
239 F.palette(p.cmdQ);
240 F.paletteClose();
241 assert.equal(dom.frames.size, 0, 'closing cancels the pending frame');
242
243 // A search past its frame, still awaiting the list, is dropped at close.
244 F.palette(p.cmdQ);
245 flushFrames();
246 F.paletteClose();
247 await settle();
248 assert.equal(p.cmdList.children.filter((li) => li.className === 'finder-hit').length, 0);
249 } finally {
250 dom.holdFrames = false;
251 }
252});
253
254test('the palette drops the results of a query that is stale', async () => {
255 const p = page();
256 p.cmdQ.value = 'tree';
257 F.palette(p.cmdQ);
258 p.cmdQ.value = 'readme'; // typed before the search of "tree" finished
259 F.palette(p.cmdQ);
260 await settle();
261 const got = p.cmdList.children.filter((li) => li.className === 'finder-hit').map((li) => li.querySelector('a').dataset.href);
262 assert.deepEqual(got, ['/blob/main/README.md']);
263
264 // A field changed without a new call: the pending search is dropped.
265 p.cmdQ.value = 'main';
266 F.palette(p.cmdQ);
267 p.cmdQ.value = 'zzzz';
268 await settle();
269 assert.deepEqual(p.cmdList.children.filter((li) => li.className === 'finder-hit'), []);
270});