import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import type { ReactNode } from "react"; // --------------------------------------------------------------------------- // Mocks // --------------------------------------------------------------------------- // usePathname is the only knob that drives "active state" + the page-title // derivation in MobileHeader. We swap it per test to verify each NAV_ITEM // gets selected when its route matches. const pathnameMock = vi.fn<() => string>(() => "/"); vi.mock("next/navigation", () => ({ usePathname: () => pathnameMock(), })); // next-themes pulls window APIs that don't exist under the SSR-only test // environment; the ThemeToggle component is rendered inside Sidebar so we // stub it to a deterministic placeholder we can grep for. vi.mock("@/components/theme-toggle", () => ({ ThemeToggle: () =>
theme-toggle
, })); // Make the Sheet primitives transparent so the drawer's contents render // inline and we can grep them. The real components defer rendering until // the trigger is clicked (Radix portal); for a contract test we just want // to confirm what's INSIDE the drawer. vi.mock("@/components/ui/sheet", () => { const passthrough = ({ children }: { children: ReactNode }) => <>{children}; return { Sheet: passthrough, SheetTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => ( <>{children} ), SheetContent: ({ children }: { children: ReactNode }) => (
{children}
), SheetHeader: ({ children }: { children: ReactNode }) =>
{children}
, SheetTitle: ({ children }: { children: ReactNode }) =>

{children}

, SheetDescription: ({ children }: { children: ReactNode }) =>

{children}

, SheetClose: passthrough, }; }); import { AppShell } from "./app-shell"; import { NAV_ITEMS } from "./nav-config"; // --------------------------------------------------------------------------- // MobileHeader contract // --------------------------------------------------------------------------- describe("AppShell — mobile header (SSR)", () => { beforeEach(() => { pathnameMock.mockReset(); pathnameMock.mockReturnValue("/"); }); it("renders a fixed top header that hides on sm+ breakpoints", () => { const html = renderToStaticMarkup(
page
, ); // A `
` exists with both `fixed top-0` and `sm:hidden` so it // covers the mobile viewport edge but yields to the sidebar on desktop. expect(html).toMatch(/]*class="[^"]*fixed top-0[^"]*sm:hidden/); }); it("brand mark on the left links to /", () => { const html = renderToStaticMarkup(
, ); // The "cm" brand pill is an with aria-label "Go home" // so screen readers announce its purpose. expect(html).toMatch(/aria-label="Go home"[^>]*href="\/"|href="\/"[^>]*aria-label="Go home"/); // Brand text is the literal "cm" inside the pill (not the page title). expect(html).toContain(">cm<"); }); it("page title in the centre reflects the active route", () => { const cases: Array<{ path: string; expected: string }> = [ { path: "/", expected: "Dashboard" }, { path: "/accounts", expected: "Accounts" }, { path: "/accounts/abc-123", expected: "Accounts" }, // sub-routes still match { path: "/reminders", expected: "Reminders" }, { path: "/reminders/new", expected: "Reminders" }, { path: "/activity", expected: "Activity" }, { path: "/settings", expected: "Settings" }, ]; for (const c of cases) { pathnameMock.mockReturnValue(c.path); const html = renderToStaticMarkup(
, ); // The mobile header has a span with the title; the desktop sidebar // doesn't include this title element. Check the title appears at // least once (mobile header) AND specifically in the expected form. expect(html).toContain(c.expected); } }); it("falls back to 'WhatsApp Bot' when the path doesn't match any nav item", () => { pathnameMock.mockReturnValue("/unknown-route"); const html = renderToStaticMarkup(
, ); // Should fall back to the generic title in the centre (and also be // present in the desktop sidebar header). expect(html).toContain("WhatsApp Bot"); }); it("menu button on the right uses aria-label='Open menu'", () => { const html = renderToStaticMarkup(
, ); expect(html).toMatch(/aria-label="Open menu"/); }); }); // --------------------------------------------------------------------------- // Menu drawer (Sheet) contents // --------------------------------------------------------------------------- describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", () => { beforeEach(() => { pathnameMock.mockReset(); pathnameMock.mockReturnValue("/"); }); it("renders one nav link per NAV_ITEM, in order", () => { const html = renderToStaticMarkup(
, ); // Find the substring inside the sheet wrapper to scope our assertions // to the drawer (avoids matching the desktop sidebar). const sheetSlice = extractSheet(html); for (const item of NAV_ITEMS) { expect(sheetSlice).toContain(`href="${item.href}"`); expect(sheetSlice).toContain(item.label); } // Order check: each label appears in the drawer in NAV_ITEMS order. let cursor = 0; for (const item of NAV_ITEMS) { const idx = sheetSlice.indexOf(item.label, cursor); expect(idx).toBeGreaterThan(-1); cursor = idx + item.label.length; } }); it("marks the active route's link with aria-current='page'", () => { pathnameMock.mockReturnValue("/reminders"); const html = renderToStaticMarkup(
, ); const sheetSlice = extractSheet(html); // The reminders link should carry aria-current; the others should not. expect(sheetSlice).toMatch(/href="\/reminders"[^>]*aria-current="page"|aria-current="page"[^>]*href="\/reminders"/); expect(sheetSlice).not.toMatch(/href="\/accounts"[^>]*aria-current="page"/); expect(sheetSlice).not.toMatch(/href="\/activity"[^>]*aria-current="page"/); }); it("Dashboard ('/') matches exactly, not as a prefix of every route", () => { // Regression guard: NAV_ITEMS contains '/' as the dashboard href. A // naïve `pathname.startsWith(href)` would mark Dashboard active on // every page. The header uses an exact-match check for "/". pathnameMock.mockReturnValue("/accounts"); const html = renderToStaticMarkup(
, ); const sheetSlice = extractSheet(html); expect(sheetSlice).not.toMatch(/href="\/"[^>]*aria-current="page"/); expect(sheetSlice).toMatch(/href="\/accounts"[^>]*aria-current="page"|aria-current="page"[^>]*href="\/accounts"/); }); it("does NOT include a theme toggle in the mobile drawer (per request)", () => { const html = renderToStaticMarkup(
, ); const sheetSlice = extractSheet(html); expect(sheetSlice).not.toContain("theme-toggle"); }); it("drawer header carries the brand wording and a screen-reader description", () => { const html = renderToStaticMarkup(
, ); const sheetSlice = extractSheet(html); // Visible title carries the brand wording. expect(sheetSlice).toContain("WhatsApp Bot"); // Description text is present (the actual sr-only class lives on the // shadcn primitive, which the mock here doesn't reproduce — so we // just assert the text is rendered, leaving a11y class testing to // the primitive's own coverage). expect(sheetSlice).toContain("Primary navigation menu"); }); }); // --------------------------------------------------------------------------- // Desktop sidebar contract // --------------------------------------------------------------------------- describe("AppShell — desktop sidebar (SSR)", () => { beforeEach(() => { pathnameMock.mockReset(); pathnameMock.mockReturnValue("/"); }); it("renders the sidebar nav with every NAV_ITEM", () => { const html = renderToStaticMarkup(
, ); // Desktop sidebar starts with `hidden sm:flex` so it's invisible on mobile. expect(html).toMatch(/]*class="[^"]*hidden sm:flex/); for (const item of NAV_ITEMS) { expect(html).toContain(item.label); } }); it("keeps the theme toggle in the sidebar footer", () => { const html = renderToStaticMarkup(
, ); // The mocked ThemeToggle prints `data-testid="theme-toggle"`; it must // appear in the sidebar (we removed it from the mobile drawer). expect(html).toContain('data-testid="theme-toggle"'); }); it("sidebar brand header is a link to / with a 'Go to dashboard' aria-label", () => { pathnameMock.mockReturnValue("/accounts"); const html = renderToStaticMarkup(
, ); // Scope to the sidebar: it's the tag cleanly separates the two brand markup blocks. */ function extractSidebar(html: string): string { const open = html.indexOf("", open); return html.slice(open, close === -1 ? html.length : close + "".length); }