davideisinger.com

My personal website
Log | Files | Refs | README

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 });