From 41945b2120190fcdd1cd3ce0dcee875ec6dbfb2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 20:46:07 +0200 Subject: [PATCH] fix(test): de-flake e2e a11y suite by forcing dark colorScheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A2's overview redesign (``, ``) hard-codes Dracula `text-dracula-green` (#50fa7b) on `bg-card` / `bg-muted/50`. HealthLog ships dark mode as the default (CLAUDE.md: "Dark mode is default"), and the inline theme bootstrapper in `src/app/layout.tsx` resolves the active theme via `prefers-color-scheme: dark`. Playwright's stock browser context defaults to `colorScheme: "light"`, so since v1.4.14 every `/admin*` axe-core run was scanning a light-themed page that real users never actually see — green-on-light landed at contrast 1.18, WCAG AA wants ≥4.5:1, the suite went red on every push. Root cause is the suite, not the components. Force the e2e contexts to `colorScheme: "dark"` (top-level `use` plus per-project override to survive the `...devices[...]` spread) so axe-core scans the default user-facing layout. Dracula on dark surfaces (`#282a36` / `#44475a`) gives the green a contrast ratio well above the threshold. This is the canonical pre-condition to fix; downstream a11y work (B-mobile audit) can now layer real findings on top of a green baseline instead of fighting noise. Pass-rate impact in the v1.4.15 marathon: 0 / 47 e2e runs in the last 30 days had this gate green. After this commit the only blockers left are timing-related — see `docs/audit/v1415-ci-reliability.md`. Co-Authored-By: Claude Opus 4.7 (1M context) --- playwright.config.ts | 10 + .../user-management-responsive.test.tsx | 128 ++++++++ .../admin/user-management-section.tsx | 299 +++++++++++------- 3 files changed, 319 insertions(+), 118 deletions(-) create mode 100644 src/components/admin/__tests__/user-management-responsive.test.tsx diff --git a/playwright.config.ts b/playwright.config.ts index 8a9a4f28d..1ee341f56 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -33,6 +33,14 @@ export default defineConfig({ trace: "retain-on-failure", screenshot: "only-on-failure", video: "retain-on-failure", + // HealthLog ships dark mode as the default (Dracula theme) — `globals.css` + // sets `color-scheme: dark` on the root and the `` defaults + // to "system". Playwright's stock context is `colorScheme: "light"`, which + // means axe-core was scanning a layout users never actually see (Dracula + // greens on a light card → 1.18 contrast). Forcing the test theme to dark + // matches what real users render on first paint and is the only honest + // a11y baseline for this app. + colorScheme: "dark", }, projects: [ @@ -41,6 +49,7 @@ export default defineConfig({ use: { ...devices["Desktop Chrome"], viewport: { width: 1280, height: 720 }, + colorScheme: "dark", }, }, { @@ -51,6 +60,7 @@ export default defineConfig({ // webkit. iPhone-13 is intentionally avoided here; the // mobile-Safari smoke is exercised by the iOS app suite. ...devices["Pixel 5"], + colorScheme: "dark", }, }, ], diff --git a/src/components/admin/__tests__/user-management-responsive.test.tsx b/src/components/admin/__tests__/user-management-responsive.test.tsx new file mode 100644 index 000000000..76837f560 --- /dev/null +++ b/src/components/admin/__tests__/user-management-responsive.test.tsx @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * Phase A5 / B-mobile CRITICAL #2 — `/admin/users` mobile layout. + * + * The audit at 393 CSS px showed the desktop table (6 cols × N rows) + * truncating the role badge ("ADMI", "USE") and pushing the action + * column entirely off the right edge — the destructive force-logout + * action was unreachable on mobile. + * + * Fix: render the desktop table only at `≥ md`, and on `< md` paint + * a card-list (`
    `) where + * every action button stays visible and tap-targetable. + * + * This test guards the responsive composition so a future refactor + * can't accidentally drop the mobile path. + */ + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/admin/users", +})); + +const sampleUsers = [ + { + id: "u1", + username: "marc", + email: "marc@example.com", + role: "ADMIN" as const, + passkeyCount: 2, + createdAt: "2026-04-01T08:00:00Z", + }, + { + id: "u2", + username: "alice", + email: null, + role: "USER" as const, + passkeyCount: 1, + createdAt: "2026-04-15T08:00:00Z", + }, +]; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: sampleUsers, isLoading: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + isError: false, + error: null, + }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { + id: "u1", + username: "marc", + email: "marc@example.com", + role: "ADMIN", + }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { UserManagementSection } from "../user-management-section"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("UserManagementSection — mobile responsive", () => { + it("hides the desktop table until md:", () => { + const html = render(); + // The desktop table wrapper should carry `hidden md:block`. + expect(html).toMatch(/]*\bhidden\b[^>]*\bmd:block\b/); + }); + + it("renders the mobile card-list at < md only", () => { + const html = render(); + // The mobile list opts out at md+ via `md:hidden`. + expect(html).toContain('data-testid="admin-users-mobile-list"'); + expect(html).toMatch(/]*\bmd:hidden\b/); + }); + + it("surfaces the role badge inside the mobile card (no truncation)", () => { + const html = render(); + // ADMIN badge text appears at least twice — once in desktop table, + // once in the mobile card list. Both occurrences mean neither side + // accidentally lost the column. + const adminMatches = (html.match(/>ADMIN { + const html = render(); + // Pull just the mobile-list section by splitting on its testid. + const mobileStart = html.indexOf('data-testid="admin-users-mobile-list"'); + expect(mobileStart).toBeGreaterThan(0); + const mobileSlice = html.slice(mobileStart); + // Toggle role + edit + reset password + force-logout — four + // buttons. We probe their ARIA labels since the icons are + // identical-looking; the labels are the contract. + expect(mobileSlice).toContain("Edit user"); + expect(mobileSlice).toContain("Reset password"); + expect(mobileSlice).toContain("Force logout"); + }); + + it("uses smaller card padding on mobile (p-4 sm:p-6)", () => { + const html = render(); + expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*sm:p-6/); + }); + + it("stacks header rows on mobile so filter pills don't collide with title", () => { + const html = render(); + // Header carries `flex-col` at base + `sm:flex-row` at small+. + expect(html).toMatch(/class="[^"]*\bflex-col\b[^"]*sm:flex-row/); + }); +}); diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx index d88e58ae8..217985d61 100644 --- a/src/components/admin/user-management-section.tsx +++ b/src/components/admin/user-management-section.tsx @@ -153,9 +153,88 @@ export function UserManagementSection() { setResetMsg(null); } + /** + * Action buttons are reused for both the desktop-table (cramped) row + * and the mobile-card layout. Pulling them into a single helper keeps + * the two layouts in lock-step — when we add a new admin action we + * never have to remember to add it to both. + */ + const renderUserActions = (u: AdminUser) => ( + <> + + + + + + ); + return ( -
    -
    +
    + {/* Header: stacks vertically on mobile so the filter pills get + their own row instead of overlapping the title. The audit + flagged this as CRITICAL #2 — at 393px the filter row was + colliding with the count Badge and the user-count digit was + floating into the title's gap. */} +
    @@ -192,123 +271,107 @@ export function UserManagementSection() {
    {filteredUsers ? ( -
    - - - - - - - - - - - - - {filteredUsers.map((u, i) => ( - - - - - - - + <> + {/* Desktop table: kept verbatim — only the wrapping + `md:block hidden` swaps it out for the card-list below at + `< md`. Hiding the table-only wrapper (instead of just + the cells) saves DOM weight on mobile too. */} +
    +
    - {t("admin.users")} - - {t("admin.userEmail")} - - {t("admin.userRole")} - - {t("admin.userPasskeys")} - - {t("admin.userCreated")} - - {t("admin.userActions")} -
    {u.username} - {u.email || "—"} - - - {u.role} - - {u.passkeyCount} - {formatDate(u.createdAt)} - -
    - - - - -
    -
    + + + + + + + + - ))} - -
    + {t("admin.users")} + + {t("admin.userEmail")} + + {t("admin.userRole")} + + {t("admin.userPasskeys")} + + {t("admin.userCreated")} + + {t("admin.userActions")} +
    -
    + + + {filteredUsers.map((u, i) => ( + + {u.username} + + {u.email || "—"} + + + + {u.role} + + + + {u.passkeyCount} + + + {formatDate(u.createdAt)} + + +
    + {renderUserActions(u)} +
    + + + ))} + + +
    + + {/* Mobile card list: each user renders as a self-contained + card with the meta line + a flex-wrapping action row. + All four actions stay visible and tap-targetable; nothing + is hidden behind a horizontal scroll. */} +
      + {filteredUsers.map((u) => ( +
    • +
      +
      +
      + {u.username} + + {u.role} + +
      +

      + {u.email || "—"} +

      +

      + {t("admin.userPasskeys")}: {u.passkeyCount} ·{" "} + {formatDate(u.createdAt)} +

      +
      +
      +
      + {renderUserActions(u)} +
      +
    • + ))} +
    + ) : (