Files
Marc-André Bombeck 3d1692204b test: add a browser Vitest project for interactive components
Run *.browser.test.tsx in headless Chromium through the Playwright
provider. The shared helper in tests/browser/ mounts the app's own
providers, stubs only the fetch boundary, and shims next/navigation
onto the page's real location and history.
2026-10-23 12:50:42 +02:00

102 lines
3.4 KiB
TypeScript

/**
* Render a component the way the app mounts it, in a real browser.
*
* The providers are the app's own: `I18nProvider` with a real catalog and a
* real TanStack `QueryClient`. The signed-in account is seeded into that
* client under `queryKeys.authMe()`, exactly where `useAuth` reads it once
* `/api/auth/me` has answered, so a component that asks for the account gets
* it without a request. Everything else a component fetches goes through the
* network and is answered by `stubFetch()` from `./fetch-stub`.
*
* The app stylesheet is loaded by `setup.ts` for every browser test, so
* `hidden`, `pointer-events-none`, popper placement and the breakpoints lay
* out as they do in the app.
*
* const { screen, queryClient } = await renderWithApp(<MeasurementForm />, {
* user: { unitPreference: "imperial" },
* });
* await screen.getByLabelText("Value (lb)").fill("210");
*/
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, type RenderResult } from "vitest-browser-react";
import type { AuthUser } from "@/hooks/use-auth";
import type { Locale } from "@/lib/i18n/config";
import { I18nProvider } from "@/lib/i18n/context";
import { loadMessages } from "@/lib/i18n/load-locale";
import { queryKeys } from "@/lib/query-keys";
/** A signed-in account with every preference at its default. */
export function authUser(overrides: Partial<AuthUser> = {}): AuthUser {
return {
id: "user-1",
username: "user",
email: null,
role: "USER",
heightCm: null,
dateOfBirth: null,
gender: null,
timezone: "UTC",
onboardingCompletedAt: "2026-01-01T00:00:00.000Z",
onboardingTourCompleted: true,
onboardingTourProgress: null,
avatarUrl: null,
glucoseUnit: null,
unitPreference: "metric",
timeFormat: "AUTO",
dateFormat: "AUTO",
disableCoach: false,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
lastReportPracticeName: null,
reportSelection: null,
cycleTrackingEnabled: false,
...overrides,
};
}
export interface RenderWithAppOptions {
/** The reader's language. Its catalog is loaded before the first render. */
locale?: Locale;
/**
* The signed-in account, merged over `authUser()`. `null` leaves the
* account unanswered, as before `/api/auth/me` returns.
*/
user?: Partial<AuthUser> | null;
/** Bring a client to seed more reads before rendering. */
queryClient?: QueryClient;
}
export interface AppRender {
screen: RenderResult;
queryClient: QueryClient;
}
/** A client configured like a test should want it: no retries, no refetch. */
export function testQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false, refetchOnWindowFocus: false },
mutations: { retry: false },
},
});
}
export async function renderWithApp(
ui: ReactNode,
{ locale = "en", user = {}, queryClient }: RenderWithAppOptions = {},
): Promise<AppRender> {
const client = queryClient ?? testQueryClient();
if (user !== null) client.setQueryData(queryKeys.authMe(), authUser(user));
if (locale !== "en") await loadMessages(locale);
const screen = await render(
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>{ui}</I18nProvider>
</QueryClientProvider>,
);
return { screen, queryClient: client };
}