navigation.cjs (15323B)
1 // Browser regression checks against a disposable Hugo site using the real theme. 2 const assert = require('node:assert/strict'); 3 const fs = require('node:fs'); 4 const path = require('node:path'); 5 const os = require('node:os'); 6 const http = require('node:http'); 7 const { execFileSync } = require('node:child_process'); 8 const { chromium } = require('playwright'); 9 10 const repo = path.resolve(__dirname, '..'); 11 const root = fs.mkdtempSync(path.join(os.tmpdir(), 'site-navigation-')); 12 let browser; 13 let server; 14 let failNextFetch = false; 15 let delayNextFetch = false; 16 let changedAssets = false; 17 function write(name, text) { 18 const file = path.join(root, name); 19 fs.mkdirSync(path.dirname(file), { recursive: true }); 20 fs.writeFileSync(file, text); 21 } 22 23 (async () => { 24 fs.cpSync(path.join(repo, 'themes/v2'), path.join(root, 'themes/v2'), { recursive: true }); 25 write('hugo.toml', 'baseURL="http://localhost/"\ntheme="v2"\ntitle="David Eisinger"\n[markup.goldmark.renderer]\nunsafe=true\n'); 26 write('layouts/partials/marky.html', ''); 27 write('content/_index.md', 'A personal website.'); 28 write('content/music/_index.md', '---\ntitle: Music\n---\n'); 29 write('content/about/index.md', '---\ntitle: About\n---\n' + 30 '[Jump to contact](#contact)\n\n' + 'A paragraph for testing scroll restoration.\n\n'.repeat(90) + 31 '## Contact\n\n[Music](/music/)\n\n' + 'More content.\n\n'.repeat(20)); 32 write('content/journal/example/index.md', '---\ntitle: Example post\ndate: 2026-01-01\ntags: [dispatch]\n---\nHello.\n\n<audio controls src="/journal/example/Night Moon.mp3"></audio>'); 33 fs.copyFileSync(path.join(repo, 'content/journal/dispatch-44-october-2026/Night Moon.mp3'), 34 path.join(root, 'content/journal/example/Night Moon.mp3')); 35 write('content/journal/older/index.md', '---\ntitle: Older post\ndate: 2025-01-01\n---\nAn earlier recording.\n\n<audio controls src="/journal/older/Older Song.mp3"></audio>\n\nAnother copy.\n\n<audio controls src="/journal/older/Older Song.mp3"></audio>'); 36 fs.copyFileSync(path.join(repo, 'content/journal/dispatch-44-october-2026/Night Moon.mp3'), 37 path.join(root, 'content/journal/older/Older Song.mp3')); 38 write('content/elsewhere/example/index.md', '---\ntitle: Elsewhere example\ndate: 2025-01-01\ncanonical_url: https://example.org/original\nexclude_music: true\n---\nOriginal article.\n\n<audio controls src="/journal/example/Night Moon.mp3"></audio>'); 39 server = http.createServer((request, response) => { 40 const isFetch = request.headers.accept === 'text/html'; 41 if (isFetch && failNextFetch) { 42 failNextFetch = false; 43 response.writeHead(503).end('Temporary failure'); 44 return; 45 } 46 const send = () => { 47 let pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname); 48 if (pathname.endsWith('/')) pathname += 'index.html'; 49 const file = path.join(root, 'public', pathname); 50 if (!fs.existsSync(file)) { response.writeHead(404).end('Not found'); return; } 51 let body = fs.readFileSync(file); 52 const type = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.mp3': 'audio/mpeg' }[path.extname(file)] || 'application/octet-stream'; 53 if (changedAssets && type === 'text/html') { 54 body = Buffer.from(body.toString().replace(/(href="\/css\/[^"?]+)(")/, '$1?v=2$2')); 55 } 56 response.writeHead(200, { 'Content-Type': type, 'Content-Length': body.length }).end(body); 57 }; 58 if (isFetch && delayNextFetch) { delayNextFetch = false; setTimeout(send, 400); } 59 else send(); 60 }); 61 await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); 62 const origin = `http://127.0.0.1:${server.address().port}`; 63 execFileSync('hugo', ['--source', root, '--baseURL', origin + '/'], { stdio: 'pipe' }); 64 const chrome = process.env.CHROME_BIN || (process.platform === 'darwin' 65 ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' : undefined); 66 browser = await chromium.launch({ executablePath: chrome, headless: true }); 67 const page = await browser.newPage({ viewport: { width: 1100, height: 800 } }); 68 const errors = []; 69 page.on('pageerror', error => errors.push(error.message)); 70 await page.goto(origin); 71 await page.waitForFunction(() => history.state?.siteNavigation); 72 await page.evaluate(async () => { 73 window.originalHeader = document.querySelector('#site-header'); 74 window.originalAudio = originalHeader.querySelector('audio'); 75 window.audioInterruptions = 0; 76 originalAudio.muted = true; 77 await originalAudio.play(); 78 originalAudio.addEventListener('pause', () => audioInterruptions++); 79 originalAudio.addEventListener('emptied', () => audioInterruptions++); 80 }); 81 await page.waitForFunction(() => originalAudio.currentTime > 0.1); 82 assert.equal(await page.locator('#latest-track-heading').textContent(), 'Now playing'); 83 84 async function click(href) { 85 await page.evaluate(href => { 86 const link = document.createElement('a'); 87 link.href = href; 88 document.querySelector('#content').append(link); 89 link.click(); 90 }, href); 91 await page.waitForFunction(href => location.pathname + location.hash === href 92 && !document.querySelector('#content').hasAttribute('aria-busy'), href); 93 } 94 async function intact() { 95 assert.equal(await page.evaluate(() => originalHeader === document.querySelector('#site-header') 96 && originalAudio === document.querySelector('#site-header audio') 97 && !originalAudio.paused && audioInterruptions === 0), true); 98 } 99 100 await click('/'); // Clicking the site title on the homepage must not reload. 101 await intact(); 102 await click('/about/'); 103 await intact(); 104 assert.equal(await page.title(), 'About – David Eisinger'); 105 assert.equal(await page.evaluate(() => document.activeElement.id), 'content'); 106 await page.evaluate(() => scrollTo(0, 700)); 107 await page.waitForFunction(() => history.state.siteNavigation.y === 700); 108 await click('/music/'); 109 await intact(); 110 assert.equal(await page.evaluate(() => scrollY), 0); 111 await page.goBack(); 112 await page.waitForFunction(() => document.title.startsWith('About') && !document.querySelector('#content').hasAttribute('aria-busy')); 113 assert.equal(await page.evaluate(() => scrollY), 700); 114 await intact(); 115 await page.goForward(); 116 await page.waitForFunction(() => document.title.startsWith('Music') && !document.querySelector('#content').hasAttribute('aria-busy')); 117 await intact(); 118 119 await click('/about/#contact'); 120 assert.equal(await page.evaluate(() => document.activeElement.id), 'contact'); 121 assert.ok(await page.evaluate(() => scrollY > 1000)); 122 await intact(); 123 // Same-page anchors and returning from them remain native and keep playback. 124 await page.evaluate(() => document.querySelector('a[href="#contact"]').click()); 125 await intact(); 126 await click('/about/'); 127 assert.equal(await page.evaluate(() => scrollY), 0); 128 await intact(); 129 await page.locator('a[href="#contact"]').click(); 130 await page.waitForFunction(() => location.hash === '#contact' && scrollY > 1000); 131 await page.goBack(); 132 await page.waitForFunction(() => !location.hash && scrollY === 0); 133 await intact(); 134 await click('/elsewhere/example/'); 135 assert.equal(await page.locator('link[rel="canonical"]').getAttribute('href'), 'https://example.org/original'); 136 await click('/music/'); 137 assert.equal(await page.locator('link[rel="canonical"]').count(), 0); 138 139 // A later click must win over an older, slower response. 140 delayNextFetch = true; 141 await page.evaluate(() => { 142 const a = document.createElement('a'); a.href = '/about/'; document.body.append(a); a.click(); a.remove(); 143 }); 144 await click('/journal/example/'); 145 await page.waitForTimeout(500); 146 assert.equal(new URL(page.url()).pathname, '/journal/example/'); 147 await intact(); 148 149 // Modified clicks, external links, file links and explicit opt-outs stay native. 150 assert.deepEqual(await page.evaluate(() => { 151 const cases = [ 152 { href: 'https://example.org/' }, { href: '/index.xml' }, { href: '/song.mp3' }, 153 { href: '/about/', target: '_blank' }, { href: '/about/', download: true }, 154 { href: '/about/', optOut: true }, { href: '/about/', ctrlKey: true }, 155 { href: '/about/', metaKey: true }, { href: '/about/', shiftKey: true }, 156 ]; 157 return cases.map(item => { 158 const a = document.createElement('a'); a.href = item.href; 159 if (item.target) a.target = item.target; 160 if (item.download) a.setAttribute('download', ''); 161 if (item.optOut) a.setAttribute('data-no-navigation', ''); 162 document.body.append(a); 163 let handled; 164 document.addEventListener('click', event => { handled = event.defaultPrevented; event.preventDefault(); }, { once: true }); 165 a.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: item.ctrlKey, metaKey: item.metaKey, shiftKey: item.shiftKey })); 166 a.remove(); return handled; 167 }); 168 }), Array(9).fill(false)); 169 170 // The music-page buttons control the persistent player, including older songs. 171 await click('/music/'); 172 assert.equal(await page.locator('#content audio:visible').count(), 0); 173 assert.equal(await page.locator('.track-toggle').count(), 2); 174 const trackTitlePositions = () => page.locator('.music-track .track-title').evaluateAll(titles => 175 titles.map(title => { 176 const rect = title.getBoundingClientRect(); 177 return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; 178 })); 179 const positionsBeforePlaying = await trackTitlePositions(); 180 await page.getByRole('button', { name: 'Play Older Song', exact: true }).click(); 181 await page.waitForFunction(() => !originalAudio.paused && originalAudio.currentTime > 0.1 182 && originalAudio.src.includes('Older%20Song.mp3')); 183 assert.equal(await page.locator('#site-header .track-title').textContent(), 'Older Song'); 184 assert.deepEqual(await trackTitlePositions(), positionsBeforePlaying); 185 assert.equal(await page.locator('#site-header .track-source a').textContent(), 'Older post'); 186 assert.equal(new URL(await page.locator('#site-header .track-source a').getAttribute('href'), origin).pathname, 187 '/journal/older/'); 188 await page.getByRole('button', { name: 'Pause Older Song', exact: true }).click(); 189 assert.equal(await page.evaluate(() => originalAudio.paused), true); 190 const pausedAt = await page.evaluate(() => originalAudio.currentTime); 191 await page.evaluate(() => originalAudio.play()); 192 await page.getByRole('button', { name: 'Pause Older Song', exact: true }).waitFor(); 193 await click('/about/'); 194 assert.equal(await page.evaluate(() => originalAudio === document.querySelector('#site-header audio') 195 && !originalAudio.paused), true); 196 await click('/music/'); 197 await page.getByRole('button', { name: 'Pause Older Song', exact: true }).waitFor(); 198 assert.ok(await page.evaluate(() => originalAudio.currentTime) >= pausedAt); 199 assert.equal(await page.locator('.track-toggle').count(), 2); 200 await page.getByRole('button', { name: 'Play Night Moon', exact: true }).click(); 201 await page.getByRole('button', { name: 'Pause Night Moon', exact: true }).waitFor(); 202 await page.getByRole('button', { name: 'Play Older Song', exact: true }).waitFor(); 203 assert.equal(await page.locator('#site-header .track-title').textContent(), 'Night Moon'); 204 assert.equal(await page.locator('#content audio').evaluateAll(players => players.every(audio => audio.paused)), true); 205 206 // Post embeds share playback and metadata, including multiple embeds and revisits. 207 await click('/journal/older/'); 208 assert.equal(await page.locator('.track-toggle').count(), 2); 209 assert.equal(await page.locator('#content audio:visible').count(), 0); 210 await page.getByRole('button', { name: 'Play Older Song', exact: true }).first().click(); 211 await page.waitForFunction(() => !originalAudio.paused && originalAudio.currentTime > 0.1 212 && originalAudio.src.includes('Older%20Song.mp3')); 213 assert.equal(await page.getByRole('button', { name: 'Pause Older Song', exact: true }).count(), 2); 214 assert.equal(await page.locator('#site-header .track-source a').textContent(), 'Older post'); 215 await click('/music/'); 216 await page.getByRole('button', { name: 'Pause Older Song', exact: true }).waitFor(); 217 await click('/journal/older/'); 218 assert.equal(await page.getByRole('button', { name: 'Pause Older Song', exact: true }).count(), 2); 219 await page.getByRole('button', { name: 'Pause Older Song', exact: true }).last().click(); 220 assert.equal(await page.evaluate(() => originalAudio.paused), true); 221 await click('/elsewhere/example/'); 222 assert.equal(await page.locator('.track-toggle').count(), 0); 223 assert.equal(await page.locator('#content audio:visible').count(), 1); 224 await click('/journal/example/'); 225 await page.getByRole('button', { name: 'Play Night Moon', exact: true }).click(); 226 await page.getByRole('button', { name: 'Pause Night Moon', exact: true }).waitFor(); 227 assert.equal(await page.locator('#site-header .track-source a').textContent(), 'Example post'); 228 229 // A failed enhanced request must retry with normal document navigation. 230 failNextFetch = true; 231 await click('/about/'); 232 await page.waitForFunction(() => !window.originalHeader); 233 assert.equal(await page.title(), 'About – David Eisinger'); 234 changedAssets = true; 235 await page.evaluate(() => { window.assetSentinel = true; }); 236 await click('/music/'); 237 await page.waitForFunction(() => !window.assetSentinel); 238 changedAssets = false; 239 240 // Direct loads and navigation continue to work without JavaScript. 241 const noJS = await browser.newContext({ javaScriptEnabled: false }); 242 const plain = await noJS.newPage(); 243 await plain.goto(origin); 244 await plain.getByRole('link', { name: 'More about me →' }).click(); 245 assert.equal(new URL(plain.url()).pathname, '/about/'); 246 assert.equal(await plain.locator('#site-header audio').count(), 1); 247 await plain.getByRole('link', { name: 'More music →' }).click(); 248 assert.equal(await plain.locator('#content audio:visible').count(), 2); 249 assert.equal(await plain.locator('.track-toggle').count(), 0); 250 await plain.goto(origin + '/journal/example/'); 251 assert.equal(await plain.locator('#content audio:visible').count(), 1); 252 assert.equal(await plain.locator('.track-toggle').count(), 0); 253 await noJS.close(); 254 255 // A direct post load enhances its embeds too. 256 await page.goto(origin + '/journal/older/'); 257 assert.equal(await page.locator('.track-toggle').count(), 2); 258 await page.getByRole('button', { name: 'Play Older Song', exact: true }).first().click(); 259 await page.getByRole('button', { name: 'Pause Older Song', exact: true }).first().waitFor(); 260 261 262 await page.setViewportSize({ width: 390, height: 850 }); 263 await page.goto(origin); 264 await click('/about/'); 265 assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); 266 assert.deepEqual(errors, []); 267 console.log('Navigation checks passed: uninterrupted audio, history/scroll, focus, fragments, canonical URLs, races, native links, fallbacks, mobile, and no-JS navigation.'); 268 })().catch(error => { console.error(error); process.exitCode = 1; }).finally(async () => { 269 await browser?.close(); 270 if (server) { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); } 271 fs.rmSync(root, { recursive: true, force: true }); 272 });