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').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].href, hrefs(p.finderList)[0]);
70 assert.match(dom.clicked[0].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].href, items[2].querySelector('a').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').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').tabIndex, -1);
189 assert.equal(hits[0].querySelector('.k').textContent, 'file');
190 assert.equal(p.cmdList.children.filter((li) => li.hasAttribute('data-cursor')).length, 1, 'one cursor');
191
192 // A longer query narrows the hits; an emptied field removes them.
193 p.cmdQ.value = 'treeh';
194 F.palette(p.cmdQ);
195 await settle();
196 assert.deepEqual(hrefs({ children: p.cmdList.children.filter((li) => li.className === 'finder-hit') }), ['/blob/main/src/treehouse.txt']);
197 p.cmdQ.value = '';
198 F.palette(p.cmdQ);
199 assert.equal(p.cmdList.children.length, 1);
200});
201
202test('Enter typed before the frame of the query runs opens the top hit', async () => {
203 const p = page();
204 release();
205 await F.load(); // the path list is loaded: only the frame is pending
206 dom.holdFrames = true;
207 try {
208 p.q.value = 'maingo';
209 F.input(p.q);
210 const e = key('Enter');
211 F.key(p.q, e);
212 assert.equal(e.prevented, true);
213 assert.equal(dom.clicked.length, 0, 'nothing to open before the frame');
214 flushFrames();
215 await settle();
216 assert.deepEqual(dom.clicked.map((a) => a.href), ['/blob/main/cmd/ermite/main.go']);
217 } finally {
218 dom.holdFrames = false;
219 }
220});
221
222test('the palette keeps one search per frame and drops a search when it closes', async () => {
223 const p = page();
224 release();
225 await F.load();
226 dom.holdFrames = true;
227 try {
228 for (const q of ['t', 'tr', 'tre', 'tree']) {
229 p.cmdQ.value = q;
230 F.palette(p.cmdQ);
231 }
232 assert.equal(dom.frames.size, 1, 'the keystrokes of a frame share one search');
233 flushFrames();
234 await settle();
235 assert.equal(p.cmdList.children.filter((li) => li.className === 'finder-hit').length, 4);
236
237 p.cmdQ.value = 'readme';
238 F.palette(p.cmdQ);
239 F.paletteClose();
240 assert.equal(dom.frames.size, 0, 'closing cancels the pending frame');
241
242 // A search past its frame, still awaiting the list, is dropped at close.
243 F.palette(p.cmdQ);
244 flushFrames();
245 F.paletteClose();
246 await settle();
247 assert.equal(p.cmdList.children.filter((li) => li.className === 'finder-hit').length, 0);
248 } finally {
249 dom.holdFrames = false;
250 }
251});
252
253test('the palette drops the results of a query that is stale', async () => {
254 const p = page();
255 p.cmdQ.value = 'tree';
256 F.palette(p.cmdQ);
257 p.cmdQ.value = 'readme'; // typed before the search of "tree" finished
258 F.palette(p.cmdQ);
259 await settle();
260 const got = p.cmdList.children.filter((li) => li.className === 'finder-hit').map((li) => li.querySelector('a').href);
261 assert.deepEqual(got, ['/blob/main/README.md']);
262
263 // A field changed without a new call: the pending search is dropped.
264 p.cmdQ.value = 'main';
265 F.palette(p.cmdQ);
266 p.cmdQ.value = 'zzzz';
267 await settle();
268 assert.deepEqual(p.cmdList.children.filter((li) => li.className === 'finder-hit'), []);
269});