diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index de1a37e5..9323ceac 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,40 +1,81 @@ import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { App } from './App'; import { SettingsProvider } from './context/SettingsContext'; import { stubMatchMedia } from './testUtils/matchMedia'; +function renderApp(initialEntry = '/news/1') { + vi.stubGlobal('scrollTo', vi.fn()); + + return render( + + + + }> + news feed

} /> + show feed

} /> +
+
+
+
+ ); +} + afterEach(() => { vi.unstubAllGlobals(); }); describe('App', () => { - it('wraps its children in the themed shell', () => { + it('renders the themed shell with the header, the routed page and the footer', () => { stubMatchMedia(false); - const { container } = render( - - -

content

-
-
- ); + const { container } = renderApp(); expect(container.querySelector('.default .body-cover')).not.toBeNull(); - expect(screen.getByText('content').parentElement).toHaveClass('wrapper'); + expect(screen.getByText('news feed').parentElement).toHaveClass('wrapper'); + expect(container.querySelector('.wrapper #header')).not.toBeNull(); + expect(container.querySelector('.wrapper #footer')).not.toBeNull(); }); it('applies the theme coming from the settings', () => { localStorage.setItem('theme', 'amoledblack'); stubMatchMedia(false); - const { container } = render( - - - - ); + const { container } = renderApp(); expect(container.querySelector('.amoledblack')).not.toBeNull(); }); + + it('sends a Google Analytics pageview on the initial render and on every navigation', async () => { + stubMatchMedia(false); + const ga = vi.fn(); + vi.stubGlobal('ga', ga); + + renderApp(); + + expect(ga.mock.calls).toEqual([ + ['set', 'page', '/news/1'], + ['send', 'pageview'], + ]); + + ga.mockClear(); + await userEvent.click(screen.getByRole('link', { name: 'show' })); + + expect(screen.getByText('show feed')).toBeInTheDocument(); + expect(ga.mock.calls).toEqual([ + ['set', 'page', '/show/1'], + ['send', 'pageview'], + ]); + }); + + it('does not throw when Google Analytics is not loaded', () => { + stubMatchMedia(false); + vi.stubGlobal('ga', undefined); + + expect(() => renderApp()).not.toThrow(); + expect(screen.getByText('news feed')).toBeInTheDocument(); + }); }); diff --git a/web/src/App.tsx b/web/src/App.tsx index ed63d362..c47d2f5c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,19 +1,38 @@ -import { ReactNode } from 'react'; +import { useEffect } from 'react'; +import { Outlet, useLocation } from 'react-router-dom'; import { useSettings } from './context/SettingsContext'; +import { Footer } from './core/Footer'; +import { Header } from './core/Header'; import './App.scss'; -export interface AppProps { - children?: ReactNode; +declare global { + interface Window { + ga?: (...args: unknown[]) => void; + } } -export function App({ children }: AppProps) { +export function App() { const { settings } = useSettings(); + const { pathname } = useLocation(); + + useEffect(() => { + if (typeof window.ga !== 'function') { + return; + } + + window.ga('set', 'page', pathname); + window.ga('send', 'pageview'); + }, [pathname]); return (
-
{children}
+
+
+ +
); } diff --git a/web/src/core/Footer.test.tsx b/web/src/core/Footer.test.tsx new file mode 100644 index 00000000..91e7c892 --- /dev/null +++ b/web/src/core/Footer.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { Footer } from './Footer'; + +describe('Footer', () => { + it('links to the project on GitHub', () => { + const { container } = render(