fasttree.dom.test.js (10070B)
1 // Browser-glue tests for app/static/fasttree.js, run in jsdom with a fake Turbo. 2 // Needs jsdom (CI installs it; locally: `npm install --no-save --no-package-lock jsdom`). 3 // Run with: node --test tests/*.test.js 4 const test = require('node:test'); 5 const assert = require('node:assert/strict'); 6 7 let JSDOM, VirtualConsole; 8 try { 9 ({ JSDOM, VirtualConsole } = require('jsdom')); 10 } catch (e) { 11 test('fasttree DOM tests', { skip: 'jsdom is not installed' }, () => {}); 12 } 13 14 if (JSDOM) { 15 const { boot } = require('../app/static/fasttree.js'); 16 17 const parts = { 18 root_header: '[RH]', count: '<c>@@n@@ @@word@@@@s@@</c>', toolbar: '[TB:@@counts@@|@@path@@]', go_up: '[UP:@@parent_path@@]', 19 row_dir: '[D:@@name@@]', row_file: '[F:@@name@@]', empty: '[EMPTY]', stats: '[ST:@@n@@@@s@@|@@size_str@@]', 20 crumb_container: '[CC:@@inner@@|@@tail@@]', crumb_link: '<@@href@@:@@name@@>', crumb_collapsed: '[COL:@@menu@@]', 21 copy_btn: '[CP:@@path@@]', visit: 'V(@@path_json@@)', 22 }; 23 const shellV = (v) => ({ v, cdn_base: 'https://cdn.example.com', parts, 24 html: 'PAGE<!--@root-header-->|<!--@breadcrumb-->|<!--@toolbar-->|<!--@list-->|<!--@stats-->|/*@visit*/' }); 25 const treeV = (v, files) => ({ v, n: files.length, files }); 26 const FILES = [['README.md', 10], ['bsc/a.pdf', 20], ['bsc/sem1/n.txt', 30], ['music/song.mp3', 40]]; 27 28 // Build a page with some links, boot the script against it, and return handles for assertions. 29 function env({ stored, turbo = true, url = 'https://cdn.example.com/', fetchImpl, extraHead = '' } = {}) { 30 const dom = new JSDOM(`<!doctype html><html><head>${extraHead}</head><body> 31 <a id="folder" href="/bsc">bsc</a> 32 <a id="deep" href="/bsc/sem1/">sem1</a> 33 <a id="root" href="/">home</a> 34 <a id="file" href="/README.md">readme</a> 35 <a id="unknown" href="/nope">nope</a> 36 <a id="query" href="/bsc?x=1">q</a> 37 <a id="hash" href="/bsc#top">h</a> 38 <a id="api" href="/api/get/bsc">api</a> 39 <a id="admin" href="/admin">admin</a> 40 <a id="blank" href="/bsc" target="_blank">b</a> 41 <a id="dl" href="/bsc" download>d</a> 42 <div data-turbo="false"><a id="noturbo" href="/bsc">n</a></div> 43 <a id="offsite" href="https://example.org/bsc">o</a> 44 </body></html>`, { url, pretendToBeVisual: true, virtualConsole: new VirtualConsole() }); 45 const win = dom.window; 46 if (stored !== undefined) win.localStorage.setItem('cdn_fast_v1', typeof stored === 'string' ? stored : JSON.stringify(stored)); 47 const visits = []; 48 if (turbo) win.Turbo = { visit: (u, o) => visits.push({ url: u, ...o }) }; 49 win.fetch = fetchImpl || (() => Promise.reject(new Error('offline'))); 50 boot(win); 51 const click = (id, init = {}) => { 52 const ev = new win.MouseEvent('click', { bubbles: true, cancelable: true, button: 0, ...init }); 53 win.document.getElementById(id).dispatchEvent(ev); 54 return ev; 55 }; 56 return { win, visits, click, api: win.__cdnFast }; 57 } 58 const saved = (tv = 't1', sv = 's1', files = FILES) => ({ tree: { v: tv, files }, shell: shellV(sv) }); 59 const jsonResponse = (body) => Promise.resolve({ ok: true, json: () => Promise.resolve(body) }); 60 61 test('warm start: a saved tree is usable immediately, with no network', () => { 62 const e = env({ stored: saved() }); 63 assert.equal(e.api.stats().ready, true); 64 assert.equal(e.api.stats().files, 4); 65 }); 66 67 test('clicking a folder link builds the page locally and hands it to Turbo', () => { 68 const e = env({ stored: saved() }); 69 const ev = e.click('folder'); 70 assert.equal(ev.defaultPrevented, true); 71 assert.equal(e.visits.length, 1); 72 const v = e.visits[0]; 73 assert.equal(v.url, '/bsc'); 74 assert.equal(v.action, 'advance'); 75 assert.equal(v.response.statusCode, 200); 76 assert.ok(v.response.responseHTML.startsWith('PAGE|[CC: </:root> / </bsc:bsc> |[CP:bsc]]|'), v.response.responseHTML); 77 assert.ok(v.response.responseHTML.includes('[UP:/]')); 78 assert.ok(v.response.responseHTML.includes('[D:sem1]') && v.response.responseHTML.includes('[F:a.pdf]')); 79 }); 80 81 test('trailing slashes and the root work', () => { 82 const e = env({ stored: saved() }); 83 assert.equal(e.click('deep').defaultPrevented, true); 84 assert.equal(e.click('root').defaultPrevented, true); 85 assert.deepEqual(e.visits.map((v) => v.url), ['/bsc/sem1/', '/']); 86 assert.ok(e.visits[1].response.responseHTML.startsWith('PAGE[RH]|')); 87 }); 88 89 test('everything else falls through to the normal navigation', () => { 90 const e = env({ stored: saved() }); 91 for (const id of ['file', 'unknown', 'query', 'hash', 'api', 'admin', 'blank', 'dl', 'noturbo', 'offsite']) { 92 assert.equal(e.click(id).defaultPrevented, false, id); 93 } 94 for (const init of [{ ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }]) { 95 assert.equal(e.click('folder', init).defaultPrevented, false, JSON.stringify(init)); 96 } 97 assert.equal(e.visits.length, 0); 98 }); 99 100 test('without a saved tree, without Turbo, or when the page opts out: no interception', () => { 101 assert.equal(env({}).click('folder').defaultPrevented, false); 102 assert.equal(env({ stored: saved(), turbo: false }).click('folder').defaultPrevented, false); 103 assert.equal(env({ stored: saved(), extraHead: '<meta name="cdn-fast" content="off">' }).click('folder').defaultPrevented, false); 104 }); 105 106 test('a corrupt or partial saved entry is ignored, not fatal', () => { 107 for (const bad of ['{not json', '{}', JSON.stringify({ tree: { v: 'x' }, shell: {} }), 'null']) { 108 const e = env({ stored: bad }); 109 assert.equal(e.api.stats().ready, false, bad); 110 assert.equal(e.click('folder').defaultPrevented, false); 111 } 112 }); 113 114 test('an error while building falls back to the normal navigation', () => { 115 const e = env({ stored: { tree: { v: 't', files: FILES }, shell: { v: 's', cdn_base: 'x', html: 'x', parts: { ...parts, row_dir: null } } } }); 116 assert.equal(e.click('folder').defaultPrevented, false); 117 assert.equal(e.visits.length, 0); 118 }); 119 120 test('handles(): what the prefetcher should leave alone', () => { 121 const e = env({ stored: saved() }); 122 assert.equal(e.api.handles('/bsc'), true); 123 assert.equal(e.api.handles('/README.md'), false); 124 assert.equal(e.api.handles('/api/get/x'), false); 125 assert.equal(e.api.handles('https://example.org/bsc'), false); 126 assert.equal(env({}).api.handles('/bsc'), false); 127 }); 128 129 test('refresh fetches the tree and shell, saves them, and enables local navigation', async () => { 130 const calls = []; 131 const e = env({ fetchImpl: (u) => { calls.push(u); return jsonResponse(u === '/api/tree' ? treeV('t9', FILES) : shellV('s9')); } }); 132 assert.equal(e.click('folder').defaultPrevented, false); // nothing yet 133 await e.api.refresh(true); 134 assert.deepEqual(calls.sort(), ['/api/shell', '/api/tree']); 135 assert.equal(e.api.stats().ready, true); 136 assert.equal(JSON.parse(e.win.localStorage.getItem('cdn_fast_v1')).tree.v, 't9'); 137 assert.equal(e.click('folder').defaultPrevented, true); 138 }); 139 140 test('forced refreshes arriving together share one request, plus one more afterwards', async () => { 141 let n = 0; 142 const e = env({ stored: saved(), fetchImpl: (u) => { n++; return jsonResponse(u === '/api/tree' ? treeV('t1', FILES) : shellV('s1')); } }); 143 await Promise.all([e.api.refresh(true), e.api.refresh(true), e.api.refresh(true)]); 144 assert.equal(n, 4, 'two requests for the flight, two more for the single follow-up'); 145 }); 146 147 test('refresh is throttled unless forced, and an offline refresh keeps what it has', async () => { 148 let n = 0; 149 const e = env({ stored: saved(), fetchImpl: () => { n++; return Promise.reject(new Error('offline')); } }); 150 await e.api.refresh(true); 151 const afterFirst = n; 152 await e.api.refresh(false); 153 assert.equal(n, afterFirst, 'throttled'); 154 assert.equal(e.api.stats().ready, true); 155 assert.equal(e.click('folder').defaultPrevented, true); 156 }); 157 158 test('a changed tree rebuilds the page on screen, but only if that page was built locally', async () => { 159 const newer = (u) => jsonResponse(u === '/api/tree' ? treeV('t2', [...FILES, ['bsc/new.pdf', 5]]) : shellV('s1')); 160 // 1) navigate locally to /bsc, then a newer tree arrives -> replace visit with the new content 161 const e = env({ stored: saved(), fetchImpl: newer }); 162 e.click('folder'); 163 e.win.history.pushState({}, '', '/bsc'); // what Turbo would do 164 e.win.document.dispatchEvent(new e.win.Event('turbo:load')); // marks the page as locally built 165 await e.api.refresh(true); 166 const last = e.visits[e.visits.length - 1]; 167 assert.equal(last.action, 'replace'); 168 assert.ok(last.response.responseHTML.includes('[F:new.pdf]')); 169 // 2) a server-rendered page is never rebuilt behind the user's back 170 const e2 = env({ stored: saved(), url: 'https://cdn.example.com/bsc', fetchImpl: newer }); 171 await e2.api.refresh(true); 172 assert.equal(e2.visits.length, 0); 173 }); 174 175 test('a folder deleted in the newer tree is re-requested from the server', async () => { 176 const gone = (u) => jsonResponse(u === '/api/tree' ? treeV('t3', [['README.md', 1]]) : shellV('s1')); 177 const e = env({ stored: saved(), fetchImpl: gone }); 178 e.click('folder'); 179 e.win.history.pushState({}, '', '/bsc'); 180 e.win.document.dispatchEvent(new e.win.Event('turbo:load')); 181 await e.api.refresh(true); 182 const last = e.visits[e.visits.length - 1]; 183 assert.deepEqual([last.url, last.action, last.response], ['/bsc', 'replace', undefined]); 184 }); 185 186 test('a too-large tree switches the feature off and clears what was saved', async () => { 187 const e = env({ stored: saved(), fetchImpl: (u) => jsonResponse(u === '/api/tree' ? { v: 'big', n: 99999, too_large: true } : shellV('s1')) }); 188 await e.api.refresh(true); 189 assert.equal(e.api.stats().ready, false); 190 assert.equal(e.win.localStorage.getItem('cdn_fast_v1'), null); 191 assert.equal(e.click('folder').defaultPrevented, false); 192 }); 193 }