mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +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
@@ -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 `<ThemeProvider>` 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",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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) => (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 px-3 text-xs"
|
||||
onClick={() =>
|
||||
updateUser.mutate({
|
||||
id: u.id,
|
||||
data: {
|
||||
role: u.role === "ADMIN" ? "USER" : "ADMIN",
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={u.id === currentUserId}
|
||||
title={
|
||||
u.id === currentUserId
|
||||
? t("admin.ownRoleUnchangeable")
|
||||
: u.role === "ADMIN"
|
||||
? t("admin.demoteToUser")
|
||||
: t("admin.promoteToAdmin")
|
||||
}
|
||||
>
|
||||
<Shield className="mr-1 h-3 w-3" aria-hidden="true" />
|
||||
{u.role === "ADMIN" ? t("admin.toUser") : t("admin.toAdmin")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 px-2 text-xs"
|
||||
onClick={() => startEdit(u)}
|
||||
title={t("admin.editUser")}
|
||||
aria-label={t("admin.editUser")}
|
||||
>
|
||||
<Pencil className="h-3 w-3" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 px-2 text-xs"
|
||||
onClick={() => startReset(u)}
|
||||
title={t("admin.resetPassword")}
|
||||
aria-label={t("admin.resetPassword")}
|
||||
>
|
||||
<KeyRound className="h-3 w-3" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive min-h-11 min-w-11 px-2 text-xs"
|
||||
onClick={() => setLogoutTarget(u)}
|
||||
disabled={u.id === currentUserId}
|
||||
title={
|
||||
u.id === currentUserId
|
||||
? t("admin.section.users.cannotLogoutSelf")
|
||||
: t("admin.section.users.forceLogout")
|
||||
}
|
||||
aria-label={
|
||||
u.id === currentUserId
|
||||
? t("admin.section.users.cannotLogoutSelf")
|
||||
: t("admin.section.users.forceLogout")
|
||||
}
|
||||
>
|
||||
<LogOut className="h-3 w-3" aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
{/* 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. */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="text-primary h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
@@ -192,123 +271,107 @@ export function UserManagementSection() {
|
||||
</div>
|
||||
|
||||
{filteredUsers ? (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.users")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.userEmail")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium">
|
||||
{t("admin.userRole")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium">
|
||||
{t("admin.userPasskeys")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.userCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.userActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{filteredUsers.map((u, i) => (
|
||||
<tr key={u.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
|
||||
<td className="px-3 py-2 font-medium">{u.username}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{u.email || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Badge
|
||||
variant={u.role === "ADMIN" ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{u.role}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">{u.passkeyCount}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{formatDate(u.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 px-3 text-xs"
|
||||
onClick={() =>
|
||||
updateUser.mutate({
|
||||
id: u.id,
|
||||
data: {
|
||||
role: u.role === "ADMIN" ? "USER" : "ADMIN",
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={u.id === currentUserId}
|
||||
title={
|
||||
u.id === currentUserId
|
||||
? t("admin.ownRoleUnchangeable")
|
||||
: u.role === "ADMIN"
|
||||
? t("admin.demoteToUser")
|
||||
: t("admin.promoteToAdmin")
|
||||
}
|
||||
>
|
||||
<Shield className="mr-1 h-3 w-3" aria-hidden="true" />
|
||||
{u.role === "ADMIN"
|
||||
? t("admin.toUser")
|
||||
: t("admin.toAdmin")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 px-2 text-xs"
|
||||
onClick={() => startEdit(u)}
|
||||
title={t("admin.editUser")}
|
||||
aria-label={t("admin.editUser")}
|
||||
>
|
||||
<Pencil className="h-3 w-3" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 px-2 text-xs"
|
||||
onClick={() => startReset(u)}
|
||||
title={t("admin.resetPassword")}
|
||||
aria-label={t("admin.resetPassword")}
|
||||
>
|
||||
<KeyRound className="h-3 w-3" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive min-h-11 min-w-11 px-2 text-xs"
|
||||
onClick={() => setLogoutTarget(u)}
|
||||
disabled={u.id === currentUserId}
|
||||
title={
|
||||
u.id === currentUserId
|
||||
? t("admin.section.users.cannotLogoutSelf")
|
||||
: t("admin.section.users.forceLogout")
|
||||
}
|
||||
aria-label={
|
||||
u.id === currentUserId
|
||||
? t("admin.section.users.cannotLogoutSelf")
|
||||
: t("admin.section.users.forceLogout")
|
||||
}
|
||||
>
|
||||
<LogOut className="h-3 w-3" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
<>
|
||||
{/* 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. */}
|
||||
<div className="mt-4 hidden overflow-x-auto md:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.users")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.userEmail")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium">
|
||||
{t("admin.userRole")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium">
|
||||
{t("admin.userPasskeys")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.userCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.userActions")}
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{filteredUsers.map((u, i) => (
|
||||
<tr key={u.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
|
||||
<td className="px-3 py-2 font-medium">{u.username}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{u.email || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Badge
|
||||
variant={u.role === "ADMIN" ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{u.role}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{u.passkeyCount}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{formatDate(u.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{renderUserActions(u)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<ul
|
||||
className="mt-4 space-y-2 md:hidden"
|
||||
data-testid="admin-users-mobile-list"
|
||||
>
|
||||
{filteredUsers.map((u) => (
|
||||
<li
|
||||
key={u.id}
|
||||
className="bg-muted/30 border-border rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{u.username}</span>
|
||||
<Badge
|
||||
variant={u.role === "ADMIN" ? "default" : "secondary"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{u.role}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{u.email || "—"}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("admin.userPasskeys")}: {u.passkeyCount} ·{" "}
|
||||
{formatDate(u.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
{renderUserActions(u)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
|
||||
Reference in new issue
Block a user