import fs from 'fs'; import path from 'path'; import React from 'react'; import {screen} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {render} from '../test-utils'; import {FileWorkerStatusContext} from '../contexts/FileWorkerStatusContext'; import {NavBar} from './Nav'; /* * Which navbar tab is marked as the current page. * * `NavLink` marks itself with `aria-current="page"` and an `active` class, and both need to * be right: the class is what the bar styles, and the attribute is what tells a screen * reader which section the user is in. * * The rule these tests encode is "a tab stays current for everything underneath it" -- * /videos/1234 is still Videos. That is `NavLink`'s default, so the tests exist to catch * an `end` prop added to a link that has pages beneath it, which silently drops the mark on * every one of those pages. */ /** * Report a viewport to fresnel, which renders only the breakpoints whose query matches: a * mock matching nothing renders no navbar at all, and one matching everything renders the * mobile and desktop bars at once. */ const useViewport = (kind) => { // Fresnel's breakpoints are mobile 0 / tablet 700 / wideTablet 880 / computer 1024. const matches = kind === 'desktop' ? (query) => query.includes('1024') : (query) => !/\d{3,}px/.test(query) || /\(max-width/.test(query); window.matchMedia = jest.fn().mockImplementation(query => ({ matches: matches(query), media: query, onchange: null, addListener: jest.fn(), removeListener: jest.fn(), addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: jest.fn(), })); }; /** * Give jsdom a bar of `barWidth` holding tabs of 60px each. * * Every width is 0 otherwise, so useOverflowNav concludes that nothing fits and sweeps all * eleven tabs into the closed "More" menu -- the bar renders no tabs at all and every * assertion about them passes vacuously. A narrow bar is how the overflow path is reached * on purpose. */ const useBarWidth = (barWidth) => { rectSpy = rectSpy || null; Object.defineProperty(HTMLElement.prototype, 'clientWidth', {configurable: true, value: barWidth}); rectSpy = jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({ width: 60, height: 40, top: 0, left: 0, right: 60, bottom: 40, x: 0, y: 0, toJSON: () => ({}), })); }; /** * Hand back the real `getBoundingClientRect`. * * The stub above is for the overflow measurement, which the mobile bar never runs -- and * floating-ui, which positions the menu's portal, walks up to the document element and * crashes the worker on a rect that is not a real DOMRect. */ const useRealRects = () => { if (rectSpy) { rectSpy.mockRestore(); rectSpy = null; } }; let rectSpy = null; beforeEach(() => { rectSpy = null; useViewport('desktop'); useBarWidth(2000); }); // NavBar reads the file-worker status for its indicator icons. That context lives outside // contexts/contexts.js, so the spec supplies it -- see the note at the top of test-utils. const fileWorkerStatus = {status: null, error: null, refresh: jest.fn(), setFastPolling: jest.fn()}; const renderNav = (route) => render(, { route, withMedia: true, contexts: [[FileWorkerStatusContext, fileWorkerStatus]], }); /** The tab marked as the current page, by its text. */ const currentTab = () => { const current = document.querySelectorAll('[aria-current="page"]'); return [...current].map(el => el.textContent.trim()); }; describe('NavBar current tab', () => { it.each([ ['/videos', 'Videos'], ['/archives', 'Archive'], ['/docs', 'Docs'], ['/map', 'Map'], ['/files', 'Files'], ['/playlists', 'Playlists'], ['/zim', 'Zim'], ['/inventory', 'Inventory'], ['/flasher', 'Flasher'], ['/more/calculators', 'Calculators'], ['/more/statistics', 'Statistics'], ])('marks %s as %s', (route, text) => { renderNav(route); // Exactly, not `toContain`: a regression leaving Home permanently marked alongside // the real tab satisfies "contains" on every row here. expect(currentTab()).toEqual([text]); }); it.each([ ['/videos/1234', 'Videos'], ['/videos/channel/5/video', 'Videos'], ['/archives/domain/example.com', 'Archive'], ['/files/some/deep/directory', 'Files'], ['/inventory/1/items', 'Inventory'], ['/more/calculators/electrical', 'Calculators'], ])('keeps the tab marked on a page underneath it: %s stays %s', (route, text) => { /* * Not where the reported bug was -- Videos never carried `end`, so opening a video * always marked it in the DOM; what was missing was the CSS that draws the mark. * These cases guard the other half: that nobody adds `end` to a link with pages * beneath it, which is what Calculators and Statistics used to have. */ renderNav(route); expect(currentTab()).toEqual([text]); }); it('marks the home link only at the root', () => { // `to='/'` matches every path unless it is told not to, which would leave the home // link permanently marked and two tabs claiming to be the current page at once. renderNav('/videos'); const home = screen.getByRole('link', {name: /WROLPi Home Icon|WROLPi/i}); expect(home).not.toHaveAttribute('aria-current'); }); it('marks exactly one tab at a time', () => { renderNav('/videos/1234'); expect(currentTab()).toHaveLength(1); }); it('marks the home link at the root', () => { renderNav('/'); expect(currentTab()).toHaveLength(1); }); it('gives the current tab the class the bar styles', () => { renderNav('/videos'); expect(document.querySelector('[aria-current="page"]')).toHaveClass('active'); }); it('marks the current item inside the mobile menu', async () => { // On a phone the bar is a hamburger, so these menu items are the only place the // mark can appear -- and `.mantine-Menu-item.active` is the only rule that draws it. useViewport('mobile'); useRealRects(); renderNav('/videos'); await userEvent.click(screen.getByRole('button', {name: 'Menu'})); const videos = await screen.findByRole('menuitem', {name: 'Videos'}); expect(videos).toHaveAttribute('aria-current', 'page'); expect(videos).toHaveClass('active'); }); it('marks the More button when the current section is folded inside it', () => { /* * A narrow bar pushes tabs into the overflow menu. Nothing in the bar was marked * then: the trigger is a plain button, not a NavLink, so the bar went blank exactly * when the window got too small to show the tab -- and the user had to open the menu * to find out where they were. */ useBarWidth(200); renderNav('/videos'); const more = screen.getByRole('button', {name: /More/}); expect(more).toHaveClass('active'); }); it('leaves the More button unmarked when the current section is not inside it', () => { useBarWidth(200); // Nothing in the bar's link list covers /admin/settings; Admin sits outside it. renderNav('/admin/settings'); expect(screen.getByRole('button', {name: /More/})).not.toHaveClass('active'); }); it('styles that class, so the current tab actually looks current', () => { /* * The reported bug in full: react-router was already marking the tab, and the mark * was already reaching the DOM -- nothing styled it, so every tab looked identical * and clicking Videos appeared to do nothing to the bar. * * jsdom applies no stylesheet, so the rule itself is the thing to guard, and it is * guarded by its declarations rather than by its selector: a check for the selector * alone still passes with the body emptied, or with the underline deleted -- and the * underline is the half that survives on a dark bar, where the wash barely reads. */ const css = fs.readFileSync(path.join(__dirname, '..', 'App.css'), 'utf8'); const ruleBody = (selector) => { const match = css.match(new RegExp(`${selector}\\s*{([^}]*)}`)); return match ? match[1] : ''; }; // The wash. expect(ruleBody('\\.wrolpi-navbar-link\\.active')).toMatch(/background:/); // The underline: drawn, in the bar's own foreground, with a height to draw. const underline = ruleBody('\\.wrolpi-navbar-link\\.active::after'); expect(underline).toMatch(/content:/); expect(underline).toMatch(/background:\s*currentColor/); // Parsed, not pattern-matched: `(?!0)` would have called 0.1875rem zero. const height = underline.match(/height:\s*([\d.]+)\s*(?:rem|em|px)/); expect(height).not.toBeNull(); expect(parseFloat(height[1])).toBeGreaterThan(0); // The menu items, which are the whole of the mark on a phone. expect(ruleBody('\\.mantine-Menu-item\\.active')).toMatch(/background:|font-weight:/); }); }); describe('NavBar bookmarks', () => { const {BookmarksContext} = require('../contexts/BookmarksContext'); const bookmarks = [ {id: 1, name: 'Channels', url: '/videos/channel', new_tab: false}, {id: 2, name: 'Services', children: [{id: 3, name: 'Jellyfin', url: ':8096/', new_tab: true}]}, ]; const renderWithBookmarks = (route, nodes) => render(, { route, withMedia: true, contexts: [ [FileWorkerStatusContext, fileWorkerStatus], [BookmarksContext, {bookmarks: nodes, error: null, refresh: jest.fn()}], ], }); it('sits between Calculators and Flasher', () => { renderNav('/'); const texts = [...document.querySelectorAll('#global_navbar .wrolpi-navbar-link')] .map(el => el.textContent.trim()); const at = texts.indexOf('Bookmarks'); expect(at).toBeGreaterThan(0); expect(texts[at - 1]).toBe('Calculators'); expect(texts[at + 1]).toBe('Flasher'); }); it('opens on click with the bookmarks, a directory flyout, and the edit link', async () => { useRealRects(); renderWithBookmarks('/', bookmarks); await userEvent.click(screen.getByRole('button', {name: /Bookmarks/})); const channels = await screen.findByRole('menuitem', {name: 'Channels'}); expect(channels).toHaveAttribute('href', '/videos/channel'); expect(channels).not.toHaveAttribute('target'); // A directory is a submenu; its bookmarks appear once it is hovered. expect(screen.queryByRole('menuitem', {name: 'Jellyfin'})).not.toBeInTheDocument(); await userEvent.hover(screen.getByRole('menuitem', {name: 'Services'})); // `hidden`: the flyout is placed by floating-ui, which jsdom cannot lay out, so it // stays hidden to accessibility queries even though it has rendered. const jellyfin = await screen.findByRole('menuitem', {name: 'Jellyfin', hidden: true}); expect(jellyfin).toHaveAttribute('href', `${window.location.protocol}//${window.location.hostname}:8096/`); expect(jellyfin).toHaveAttribute('target', '_blank'); expect(screen.getByRole('menuitem', {name: 'Edit bookmarks', hidden: true})).toHaveAttribute('href', '/more/bookmarks'); }); it('offers the edit link even with no bookmarks, so the feature can be found', async () => { useRealRects(); renderWithBookmarks('/', []); await userEvent.click(screen.getByRole('button', {name: /Bookmarks/})); expect(await screen.findByRole('menuitem', {name: 'Edit bookmarks'})).toBeInTheDocument(); expect(screen.getAllByRole('menuitem')).toHaveLength(1); }); it('is marked current on the editor page, and never for a bookmark pointing at a section', () => { renderWithBookmarks('/more/bookmarks', bookmarks); expect(screen.getByRole('button', {name: /Bookmarks/})).toHaveClass('active'); expect(currentTab()).toEqual([]); }); it('marks More when the editor page is folded inside it', () => { useBarWidth(200); renderWithBookmarks('/more/bookmarks', bookmarks); expect(screen.getByRole('button', {name: /More/})).toHaveClass('active'); }); it('does not own the section a bookmark points at', () => { renderWithBookmarks('/videos/channel', bookmarks); expect(screen.getByRole('button', {name: /Bookmarks/})).not.toHaveClass('active'); expect(currentTab()).toEqual(['Videos']); }); it('lists bookmarks as a labelled group in the mobile menu', async () => { useViewport('mobile'); useRealRects(); renderWithBookmarks('/', bookmarks); await userEvent.click(screen.getByRole('button', {name: 'Menu'})); expect(await screen.findByRole('menuitem', {name: 'Channels'})).toBeInTheDocument(); // No flyout on a phone: the directory's bookmarks are listed directly. expect(screen.getByRole('menuitem', {name: 'Jellyfin'})).toBeInTheDocument(); expect(screen.getByText('Services')).toBeInTheDocument(); expect(screen.getByRole('menuitem', {name: 'Edit bookmarks'})).toBeInTheDocument(); }); });