prefetch.test.js (6855B)
1 // Run with: node --test tests/*.test.js 2 const test = require('node:test'); 3 const assert = require('node:assert/strict'); 4 const { DEFAULTS, pathAllowed, candidateUrl, createPrefetcher } = require('../app/static/prefetch.js'); 5 6 const BASE = new URL('https://cdn.example.com/photos/'); 7 const link = (href, extra = {}) => ({ href, ...extra }); 8 const pick = (href, extra) => candidateUrl(link(href, extra), BASE); 9 10 test('allows folders, files, and top-level pages', () => { 11 assert.equal(pick('/uploads/'), 'https://cdn.example.com/uploads/'); 12 assert.equal(pick('/uploads/report.pdf'), 'https://cdn.example.com/uploads/report.pdf'); 13 assert.equal(pick('/find'), 'https://cdn.example.com/find'); 14 assert.equal(pick('/documentation'), 'https://cdn.example.com/documentation'); 15 assert.equal(pick('sub/dir'), 'https://cdn.example.com/photos/sub/dir'); // relative 16 }); 17 18 test('never prefetches the app’s own endpoints, downloads or redirects', () => { 19 for (const p of ['/api/get/uploads/big.zip', '/api/pack/uploads', '/admin', '/admin/x', 20 '/mask/abc123', '/pack/batch1', '/static/sw.js', '/raw/uploads/a.png', 21 '/favicon.ico']) { 22 assert.equal(pick(p), null, p); 23 } 24 }); 25 26 test('reserved names only match whole path segments', () => { 27 assert.equal(pathAllowed('/administrator/'), true); 28 assert.equal(pathAllowed('/apikeys/'), true); 29 assert.equal(pathAllowed('/api'), false); 30 assert.equal(pathAllowed('/'), true); 31 }); 32 33 test('skips other origins and non-http schemes', () => { 34 assert.equal(pick('https://raw.cdn.example.com/uploads/a.png'), null); 35 assert.equal(pick('https://example.org/'), null); 36 assert.equal(pick('mailto:a@b.c'), null); 37 assert.equal(pick('javascript:void(0)'), null); 38 }); 39 40 test('respects opt-outs on the link', () => { 41 assert.equal(pick('/uploads/', { download: true }), null); 42 assert.equal(pick('/uploads/', { target: '_blank' }), null); 43 assert.equal(pick('/uploads/', { target: '_self' }), 'https://cdn.example.com/uploads/'); 44 assert.equal(pick('/uploads/', { turboOff: true }), null); 45 assert.equal(pick('/uploads/', { noPrefetch: true }), null); 46 }); 47 48 test('skips the current page, strips hashes', () => { 49 assert.equal(pick('/photos/'), null); 50 assert.equal(pick('/photos/#top'), null); 51 assert.equal(pick('/uploads/#x'), 'https://cdn.example.com/uploads/'); 52 assert.equal(pick('/photos/?q=1'), 'https://cdn.example.com/photos/?q=1'); 53 }); 54 55 // --- queue --------------------------------------------------------------------------------------- 56 57 // A fetch whose responses the test finishes by hand, so we can observe concurrency. 58 function manualFetch() { 59 const calls = []; 60 const fn = (url, signal) => 61 new Promise((resolve, reject) => { 62 const call = { url, signal, resolve, reject, aborted: false }; 63 signal.addEventListener('abort', () => { call.aborted = true; reject(new Error('aborted')); }); 64 calls.push(call); 65 }); 66 return { fn, calls }; 67 } 68 const tick = () => new Promise((r) => setImmediate(r)); 69 const urls = (n) => Array.from({ length: n }, (_, i) => `https://cdn.example.com/f${i}/`); 70 71 test('runs at most `concurrency` fetches at once and drains the queue', async () => { 72 const { fn, calls } = manualFetch(); 73 const pf = createPrefetcher({ fetchFn: fn, config: { concurrency: 2, maxLinks: 10 } }); 74 pf.enqueue(urls(5)); 75 await tick(); 76 assert.equal(calls.length, 2); 77 calls[0].resolve(); 78 await tick(); 79 assert.equal(calls.length, 3); 80 calls.forEach((c) => c.resolve()); 81 await tick(); await tick(); 82 assert.equal(calls.length, 5); 83 }); 84 85 test('stops accepting links after maxLinks per page view, and newPage resets the budget', async () => { 86 const { fn } = manualFetch(); 87 const pf = createPrefetcher({ fetchFn: fn, config: { maxLinks: 3 } }); 88 assert.equal(pf.enqueue(urls(10)), 3); 89 assert.equal(pf.remaining(), 0); 90 assert.equal(pf.enqueue(urls(10)), 0); 91 pf.newPage(); 92 assert.equal(pf.remaining(), 3); 93 }); 94 95 test('does not refetch inside the TTL, does after it', async () => { 96 let t = 1000; 97 const { fn, calls } = manualFetch(); 98 const pf = createPrefetcher({ fetchFn: fn, now: () => t, config: { ttlMs: 50000, maxLinks: 10 } }); 99 pf.enqueue(urls(1)); 100 await tick(); 101 calls[0].resolve(); 102 await tick(); 103 pf.newPage(); 104 assert.equal(pf.enqueue(urls(1)), 0); // still fresh 105 t += 60000; 106 assert.equal(pf.enqueue(urls(1)), 1); // expired 107 }); 108 109 test('does not queue a URL that is already in flight', async () => { 110 const { fn } = manualFetch(); 111 const pf = createPrefetcher({ fetchFn: fn, config: { maxLinks: 10 } }); 112 pf.enqueue(urls(1)); 113 await tick(); 114 pf.newPage(); 115 assert.equal(pf.enqueue(urls(1)), 0); 116 }); 117 118 test('abort cancels in-flight requests and clears the queue', async () => { 119 const { fn, calls } = manualFetch(); 120 const pf = createPrefetcher({ fetchFn: fn, config: { concurrency: 1, maxLinks: 10 } }); 121 pf.enqueue(urls(4)); 122 await tick(); 123 pf.abort(); 124 await tick(); 125 assert.equal(calls[0].aborted, true); 126 assert.equal(calls.length, 1); // nothing else started 127 assert.equal(pf.stats().queued, 0); 128 // an aborted fetch is not remembered as done, so it can be tried again later 129 pf.newPage(); 130 assert.equal(pf.enqueue(urls(1)), 1); 131 }); 132 133 test('a failing fetch does not stall the queue and is not retried in a loop', async () => { 134 const { fn, calls } = manualFetch(); 135 const pf = createPrefetcher({ fetchFn: fn, config: { concurrency: 1, maxLinks: 10 } }); 136 pf.enqueue(urls(2)); 137 await tick(); 138 calls[0].reject(new Error('network')); 139 await tick(); await tick(); 140 assert.equal(calls.length, 2); // moved on to the next one 141 pf.newPage(); 142 assert.equal(pf.enqueue(urls(1)), 0); // failed URL remembered for the TTL 143 }); 144 145 test('paused queue holds work until resumed', async () => { 146 const { fn, calls } = manualFetch(); 147 const pf = createPrefetcher({ fetchFn: fn, config: { concurrency: 2, maxLinks: 10 } }); 148 pf.setPaused(true); 149 pf.enqueue(urls(3)); 150 await tick(); 151 assert.equal(calls.length, 0); 152 pf.setPaused(false); 153 await tick(); 154 assert.equal(calls.length, 2); 155 }); 156 157 test('abort(keepUrl) leaves the page being opened running and cancels the rest', async () => { 158 const { fn, calls } = manualFetch(); 159 const pf = createPrefetcher({ fetchFn: fn, config: { concurrency: 3, maxLinks: 10 } }); 160 const [a, b, c] = urls(3); 161 pf.enqueue([a, b, c, ...urls(6).slice(3)]); 162 await tick(); 163 pf.abort(b); 164 await tick(); 165 assert.deepEqual(calls.map((x) => x.aborted), [true, false, true]); 166 assert.equal(pf.stats().queued, 0); 167 calls[1].resolve(); // the kept request finishes normally and is remembered 168 await tick(); await tick(); 169 pf.newPage(); 170 assert.equal(pf.enqueue([b]), 0); 171 }); 172 173 test('prefetch TTL is shorter than the pages\' browser-cache lifetime (30 s)', () => { 174 assert.ok(DEFAULTS.ttlMs < 30000); 175 });