From f24619e3d6785645899d2c365c1836896eaecfea Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 12:50:54 +0800 Subject: [PATCH] test(app-shell): cover header + menu drawer + sidebar; full-width status tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12 new SSR tests in app-shell.test.tsx: Mobile header - Fixed top header is rendered with `sm:hidden` so it disappears on the desktop breakpoint. - Brand mark on the left links home and carries `aria-label="Go home"`. - Page title in the centre is derived from usePathname: * "/" → "Dashboard" * "/accounts/123" → "Accounts" (sub-route falls back to parent label) * unknown route → generic "WhatsApp Bot" - Menu button on the right is labelled `aria-label="Open menu"`. Menu drawer (Sheet primitives mocked transparent so SSR shows content) - Renders one nav link per NAV_ITEM, in declared order. - The active route's link gets `aria-current="page"`; others don't. - Dashboard ("/") matches by exact equality, not by `startsWith`, so every page doesn't get marked Dashboard. - The drawer does NOT include the theme toggle — it lives only in the desktop sidebar footer per the recent product call. - Drawer header carries the brand wording and the SR-only nav-menu description. Desktop sidebar - Renders with `hidden sm:flex` (mobile-hidden, desktop-visible). - All NAV_ITEMS appear. - Theme toggle is present in the sidebar footer. Plus the small follow-up the user pointed at: UI: status tabs span the full row - The shadcn `` defaults to `inline-flex w-fit`, which packed Active/Ended/Paused into a tight cluster on the left of the reminders + activity pages. Added `w-full` to both `` invocations so the tabs distribute evenly across the available row width (`flex-1` on each `` already handles even widths once the parent stretches). Total: 206 web tests passing (was 194; +12 from app-shell.test.tsx). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/app/activity/page.tsx | 2 +- apps/web/src/app/reminders/page.tsx | 2 +- apps/web/src/components/app-shell.test.tsx | 267 +++++++++++++++++++++ 3 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/app-shell.test.tsx diff --git a/apps/web/src/app/activity/page.tsx b/apps/web/src/app/activity/page.tsx index 947ca75..01572c8 100644 --- a/apps/web/src/app/activity/page.tsx +++ b/apps/web/src/app/activity/page.tsx @@ -216,7 +216,7 @@ export default async function ActivityPage({ searchParams }: PageProps) { - + {FILTER_TABS.map(({ value, label }) => ( {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} diff --git a/apps/web/src/app/reminders/page.tsx b/apps/web/src/app/reminders/page.tsx index b4dc5bc..491b6bd 100644 --- a/apps/web/src/app/reminders/page.tsx +++ b/apps/web/src/app/reminders/page.tsx @@ -198,7 +198,7 @@ export default async function RemindersPage({ searchParams }: PageProps) { {/* Status tabs — preserve other filter params so flipping tabs doesn't lose them */} - + {FILTER_TABS.map(({ value, label }) => ( {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} diff --git a/apps/web/src/components/app-shell.test.tsx b/apps/web/src/components/app-shell.test.tsx new file mode 100644 index 0000000..86f6557 --- /dev/null +++ b/apps/web/src/components/app-shell.test.tsx @@ -0,0 +1,267 @@ +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"'); + }); +}); + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- +/** + * Slice off everything from the SheetContent marker onward. The + * AppShell renders first and (which owns + * the Sheet) second, so anything after the marker belongs to the + * mobile drawer + its surrounding JSX (the closing tags). This avoids + * matching the desktop sidebar's nav links, which would otherwise + * trigger false positives. + * + * We can't reliably scope to "just the SheetContent div" without an + * HTML parser — the slice includes a few closing tags from outer + * elements, but those don't introduce false matches for our + * assertions (they have no href / aria-current attributes). + */ +function extractSheet(html: string): string { + const open = html.indexOf('data-testid="sheet-content"'); + if (open === -1) return ""; + return html.slice(open); +}