mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(test): de-flake e2e a11y suite by forcing dark colorScheme
A2's overview redesign (`<SystemStatusSummary>`, `<RecentAuditPreview>`) 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) <noreply@anthropic.com>
This commit is contained in:
1 parent
316c3b0824
commit
41945b2120
3 files changed
+319
-118
No files matched your search
@@ -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 (`<ul data-testid="admin-users-mobile-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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<UserManagementSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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(/<div[^>]*\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(/<ul[^>]*\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</g) ?? []).length;
|
||||
expect(adminMatches).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("renders all four action buttons inside the mobile card", () => {
|
||||
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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user