diff --git a/src/__tests__/baselines/test-source-reads.json b/src/__tests__/baselines/test-source-reads.json
index 4cafc7881..3e6c5204c 100644
--- a/src/__tests__/baselines/test-source-reads.json
+++ b/src/__tests__/baselines/test-source-reads.json
@@ -1,7 +1,7 @@
{
"$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
- "count": 358,
- "total": 358,
+ "count": 357,
+ "total": 357,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 1,
"__tests__/admin-data-wipe-completeness.test.ts": 1,
@@ -213,7 +213,6 @@
"components/layout/__tests__/offline-banner.test.tsx": 1,
"components/layout/__tests__/sidebar-nav.test.tsx": 1,
"components/measurements/__tests__/duration-read-as.test.tsx": 1,
- "components/measurements/__tests__/measurement-form-glucose-unit.test.tsx": 1,
"components/medications/__tests__/add-inventory-dialog.test.tsx": 1,
"components/medications/__tests__/detail-page-cascade.test.tsx": 1,
"components/medications/__tests__/inventory-section-dates.test.tsx": 1,
diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json
index 8e942a370..bf030cc41 100644
--- a/src/__tests__/baselines/version-stamp-comments.json
+++ b/src/__tests__/baselines/version-stamp-comments.json
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
- "count": 2949,
- "total": 10293,
+ "count": 2948,
+ "total": 10292,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -1122,7 +1122,6 @@
"components/measurement-reminders/vorsorge-dashboard-card.tsx": 2,
"components/measurement-reminders/vorsorge-section.tsx": 26,
"components/measurement-reminders/vorsorge-settings.tsx": 2,
- "components/measurements/__tests__/measurement-form-unit-label.test.tsx": 1,
"components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx": 4,
"components/measurements/__tests__/measurement-list-bp-badge.test.tsx": 3,
"components/measurements/__tests__/measurement-list-empty.test.tsx": 1,
diff --git a/src/components/measurements/__tests__/measurement-form-glucose-unit.test.tsx b/src/components/measurements/__tests__/measurement-form-glucose-unit.test.tsx
deleted file mode 100644
index af91ed46d..000000000
--- a/src/components/measurements/__tests__/measurement-form-glucose-unit.test.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import { readFileSync } from "node:fs";
-import { join } from "node:path";
-import { describe, expect, it, vi } from "vitest";
-import { renderToStaticMarkup } from "react-dom/server";
-
-/**
- * The manual entry form and the blood-glucose display unit.
- *
- * Glucose is stored in mg/dL and shown in whichever of mg/dL and mmol/L the
- * account picked. Until the preference could be set at all, the form could
- * hardcode "mg/dL" and be right by accident. It no longer can: a reader on
- * mmol/L types 5.3, and 5.3 filed as mg/dL is a severe hypo that the
- * plausibility band (20–800) accepts without a word and every surface then
- * reads back as real. So the label and the stored number have to move
- * together — the field asks in the account's unit and the submit path
- * inverts to canonical mg/dL.
- *
- * The label half is rendered. The submit half is asserted against the source:
- * this project runs SSR-only component tests (`@testing-library/react` is not
- * a dependency), so nothing here can type into the field and press save. What
- * the source assertion pins is the wiring — that the glucose branch inverts
- * through `toCanonicalMgdl` rather than sending the typed number on — and
- * `src/lib/__tests__/glucose.test.ts` proves the conversion itself.
- */
-
-let glucoseUnit: string | null = null;
-
-vi.mock("@/hooks/use-auth", () => ({
- useAuth: () => ({
- user: {
- id: "u1",
- username: "testuser",
- role: "USER",
- unitPreference: "metric",
- glucoseUnit,
- },
- isAuthenticated: true,
- isLoading: false,
- }),
-}));
-
-vi.mock("@tanstack/react-query", () => ({
- useQueryClient: () => ({
- invalidateQueries: vi.fn(),
- refetchQueries: vi.fn(),
- }),
-}));
-
-vi.mock("@/lib/api/api-fetch", () => ({
- apiPost: vi.fn().mockResolvedValue({}),
-}));
-
-import { I18nProvider } from "@/lib/i18n/context";
-import { MeasurementForm } from "../measurement-form";
-
-function render(): string {
- return renderToStaticMarkup(
-
-
- ,
- );
-}
-
-const formSource = readFileSync(
- join(process.cwd(), "src/components/measurements/measurement-form.tsx"),
- "utf8",
-);
-
-describe("MeasurementForm — blood-glucose entry unit", () => {
- it("asks in mg/dL for an account that never chose", () => {
- glucoseUnit = null;
- const html = render();
- expect(html).toContain("mg/dL");
- expect(html).not.toContain("mmol/L");
- expect(html).toContain('placeholder="95"');
- });
-
- it("asks in mmol/L once the account chose it", () => {
- glucoseUnit = "mmol/L";
- const html = render();
- expect(html).toContain("mmol/L");
- // A mmol/L reader must not be shown a three-digit mg/dL example.
- expect(html).toContain('placeholder="5.3"');
- expect(html).not.toContain('placeholder="95"');
- });
-
- it("inverts a glucose entry to canonical mg/dL before it is sent", () => {
- expect(formSource).toMatch(
- /if \(isGlucoseMode\) \{\s*canonicalValue = toCanonicalMgdl\(typed, glucoseUnit\);/,
- );
- });
-
- it("resolves the unit from the account rather than assuming one", () => {
- expect(formSource).toMatch(
- /const glucoseUnit = resolveGlucoseUnit\(user\?\.glucoseUnit\)/,
- );
- });
-});
diff --git a/src/components/measurements/__tests__/measurement-form-module-types.test.tsx b/src/components/measurements/__tests__/measurement-form-module-types.test.ts
similarity index 60%
rename from src/components/measurements/__tests__/measurement-form-module-types.test.tsx
rename to src/components/measurements/__tests__/measurement-form-module-types.test.ts
index 98a2ec7c9..12e4efd15 100644
--- a/src/components/measurements/__tests__/measurement-form-module-types.test.tsx
+++ b/src/components/measurements/__tests__/measurement-form-module-types.test.ts
@@ -6,45 +6,18 @@
* file a reading into a domain whose pages, charts and reads are all hidden,
* or be refused by the route. The form now filters its types through the
* surface map's type ownership (`summary:`), which the test below holds
- * equal to the measurement-scope ownership the server gates on.
+ * equal to the measurement-scope ownership the server gates on. That the
+ * mounted form neither opens on nor offers such a type is driven in
+ * `measurement-form.browser.test.tsx`.
*/
-import { renderToStaticMarkup } from "react-dom/server";
-import { describe, expect, it, vi } from "vitest";
+import { describe, expect, it } from "vitest";
import type { MeasurementType } from "@/generated/prisma/client";
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
import { surfaceModule } from "@/lib/modules/surface";
-const auth = vi.hoisted(() => ({
- modules: undefined as Record | undefined,
-}));
-vi.mock("@/hooks/use-auth", () => ({
- useAuth: () => ({
- user: {
- id: "u1",
- role: "USER",
- unitPreference: "metric",
- glucoseUnit: null,
- modules: auth.modules,
- },
- isAuthenticated: true,
- isLoading: false,
- }),
-}));
-vi.mock("@tanstack/react-query", () => ({
- useQueryClient: () => ({
- invalidateQueries: vi.fn(),
- refetchQueries: vi.fn(),
- }),
-}));
-vi.mock("@/lib/api/api-fetch", () => ({
- apiPost: vi.fn().mockResolvedValue({}),
-}));
-
-import { I18nProvider } from "@/lib/i18n/context";
import {
MEASUREMENT_FORM_TYPE_VALUES,
- MeasurementForm,
measurementFormTypes,
} from "../measurement-form";
@@ -77,17 +50,3 @@ describe("measurementFormTypes", () => {
expect(owned).toBeGreaterThan(0);
});
});
-
-describe(" with a type's module off", () => {
- it("does not open on that type, even when asked to", () => {
- auth.modules = { glucose: false };
- const html = renderToStaticMarkup(
-
-
- ,
- );
- auth.modules = undefined;
- expect(html).not.toContain("mg/dL");
- expect(html).toContain("mmHg");
- });
-});
diff --git a/src/components/measurements/__tests__/measurement-form-unit-label.test.tsx b/src/components/measurements/__tests__/measurement-form-unit-label.test.tsx
deleted file mode 100644
index b53d0d50a..000000000
--- a/src/components/measurements/__tests__/measurement-form-unit-label.test.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { renderToStaticMarkup } from "react-dom/server";
-
-/**
- * v1.32.26 (issue #627) — the manual entry form labels its value field in the
- * user's preferred unit for a type with a metric/imperial transform. A metric
- * user sees "kg"; an imperial user sees "lb". The field is the entry boundary
- * that converts the typed number back to canonical SI on submit, so the label
- * and the stored unit must agree with the preference.
- */
-
-let preference: "metric" | "imperial" = "metric";
-
-vi.mock("@/hooks/use-auth", () => ({
- useAuth: () => ({
- user: {
- id: "u1",
- username: "testuser",
- role: "USER",
- unitPreference: preference,
- },
- isAuthenticated: true,
- isLoading: false,
- }),
-}));
-
-vi.mock("@tanstack/react-query", () => ({
- useQueryClient: () => ({
- invalidateQueries: vi.fn(),
- refetchQueries: vi.fn(),
- }),
-}));
-
-vi.mock("@/lib/api/api-fetch", () => ({
- apiPost: vi.fn().mockResolvedValue({}),
-}));
-
-import { I18nProvider } from "@/lib/i18n/context";
-import { MeasurementForm } from "../measurement-form";
-
-function render(): string {
- return renderToStaticMarkup(
-
-
- ,
- );
-}
-
-describe("MeasurementForm — preferred-unit value label", () => {
- it("labels the weight field in kg for a metric user", () => {
- preference = "metric";
- const html = render();
- expect(html).toContain("kg");
- expect(html).not.toContain("lb");
- // The metric placeholder (kg) is shown, not the imperial one.
- expect(html).toContain('placeholder="75.5"');
- });
-
- it("labels the weight field in lb for an imperial user", () => {
- preference = "imperial";
- const html = render();
- expect(html).toContain("lb");
- // The imperial placeholder (lb) is shown.
- expect(html).toContain('placeholder="165"');
- });
-});
diff --git a/src/components/measurements/__tests__/measurement-form.browser.test.tsx b/src/components/measurements/__tests__/measurement-form.browser.test.tsx
new file mode 100644
index 000000000..10a5b9007
--- /dev/null
+++ b/src/components/measurements/__tests__/measurement-form.browser.test.tsx
@@ -0,0 +1,309 @@
+/**
+ * `` driven by typing and pressing Save, with the request
+ * body read off the network boundary. What the markup tests could only pin
+ * against the source is asserted on the wire here: a weight typed in lb, a
+ * glucose typed in mmol/L and a sleep typed as "7:30" each arrive in their
+ * canonical unit.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { userEvent } from "vitest/browser";
+
+import type { AuthUser } from "@/hooks/use-auth";
+
+import { pinDate } from "../../../../tests/browser/clock";
+import {
+ stubFetch,
+ type FetchStub,
+} from "../../../../tests/browser/fetch-stub";
+import { renderWithApp } from "../../../../tests/browser/render";
+import { MeasurementForm } from "../measurement-form";
+
+const NOW = "2026-09-15T10:00:00.000Z";
+
+let api: FetchStub;
+
+beforeEach(() => {
+ pinDate(NOW);
+ api = stubFetch().on("POST", "/api/measurements", { status: 201, data: {} });
+});
+
+async function mount(
+ props: Parameters[0] = {},
+ user: Partial = {},
+) {
+ const onSuccess = vi.fn();
+ const { screen } = await renderWithApp(
+ {}} {...props} />,
+ { user },
+ );
+ const save = screen.getByRole("button", { name: "Save" });
+ return { screen, onSuccess, save };
+}
+
+const posted = () => api.calls("POST", "/api/measurements").map((r) => r.body);
+
+describe(" blood pressure", () => {
+ it("sends systolic, diastolic and pulse as one batch at the chosen instant", async () => {
+ const { screen, save, onSuccess } = await mount({
+ defaultType: "BLOOD_PRESSURE",
+ });
+ await screen.getByLabelText(/^Systolic/).fill("128");
+ await screen.getByLabelText(/^Diastolic/).fill("84");
+ await screen.getByLabelText(/^Pulse/).fill("66");
+ await screen.getByLabelText(/^Notes/).fill("after the stairs");
+ await save.click();
+
+ await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
+ const at = NOW;
+ expect(posted()).toEqual([
+ [
+ {
+ type: "BLOOD_PRESSURE_SYS",
+ value: 128,
+ measuredAt: at,
+ notes: "after the stairs",
+ },
+ {
+ type: "BLOOD_PRESSURE_DIA",
+ value: 84,
+ measuredAt: at,
+ notes: "after the stairs",
+ },
+ { type: "PULSE", value: 66, measuredAt: at, notes: "after the stairs" },
+ ],
+ ]);
+ // Only the write: nothing else in the cache needed refetching.
+ expect(api.requests.map((r) => `${r.method} ${r.path}`)).toEqual([
+ "POST /api/measurements",
+ ]);
+ await expect.element(screen.getByLabelText(/^Systolic/)).toHaveValue(null);
+ });
+
+ it("leaves the pulse out when it was not typed", async () => {
+ const { screen, save, onSuccess } = await mount({
+ defaultType: "BLOOD_PRESSURE",
+ });
+ await screen.getByLabelText(/^Systolic/).fill("118");
+ await screen.getByLabelText(/^Diastolic/).fill("76");
+ await save.click();
+ await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
+ const [batch] = posted() as Array>;
+ expect(batch.map((e) => e.type)).toEqual([
+ "BLOOD_PRESSURE_SYS",
+ "BLOOD_PRESSURE_DIA",
+ ]);
+ });
+
+ it("sends nothing while a required reading is missing", async () => {
+ const { screen, save, onSuccess } = await mount({
+ defaultType: "BLOOD_PRESSURE",
+ });
+ await screen.getByLabelText(/^Diastolic/).fill("80");
+ await save.click();
+ const sys = screen
+ .getByLabelText(/^Systolic/)
+ .element() as HTMLInputElement;
+ expect(sys.validity.valueMissing).toBe(true);
+ expect(api.requests).toEqual([]);
+ expect(onSuccess).not.toHaveBeenCalled();
+ });
+
+ it("holds Save and the menu while the write is pending", async () => {
+ const pending = api.deferred("POST", "/api/measurements");
+ const { screen, save, onSuccess } = await mount({
+ defaultType: "BLOOD_PRESSURE",
+ });
+ await screen.getByLabelText(/^Systolic/).fill("120");
+ await screen.getByLabelText(/^Diastolic/).fill("80");
+ await save.click();
+
+ await expect.element(save).toBeDisabled();
+ await expect
+ .element(screen.getByRole("button", { name: "More options" }))
+ .toBeDisabled();
+ await expect
+ .element(screen.getByRole("button", { name: "Cancel" }))
+ .toBeDisabled();
+ expect(onSuccess).not.toHaveBeenCalled();
+
+ pending.release({ status: 201, data: {} });
+ await expect.element(save).toBeEnabled();
+ expect(onSuccess).toHaveBeenCalledOnce();
+ });
+
+ it("names a refused write in the reader's words and keeps what was typed", async () => {
+ api.on("POST", "/api/measurements", {
+ status: 409,
+ error: "Duplicate measurement",
+ meta: { errorCode: "measurement.duplicate_timestamp" },
+ });
+ const { screen, save, onSuccess } = await mount({
+ defaultType: "BLOOD_PRESSURE",
+ });
+ await screen.getByLabelText(/^Systolic/).fill("120");
+ await screen.getByLabelText(/^Diastolic/).fill("80");
+ await save.click();
+
+ await expect
+ .element(screen.getByRole("alert"))
+ .toHaveTextContent("A measurement already exists for this time.");
+ await expect
+ .element(screen.getByLabelText(/^Systolic/))
+ .toHaveAttribute("aria-invalid", "true");
+ await expect.element(screen.getByLabelText(/^Systolic/)).toHaveValue(120);
+ expect(onSuccess).not.toHaveBeenCalled();
+ });
+});
+
+describe(" units on the way out", () => {
+ it("asks an imperial reader for lb and stores kg", async () => {
+ const { screen, save, onSuccess } = await mount(
+ { defaultType: "WEIGHT" },
+ { unitPreference: "imperial" },
+ );
+ const field = screen.getByLabelText("Value (lb)");
+ await expect.element(field).toHaveAttribute("placeholder", "165");
+ await field.fill("210");
+ await save.click();
+ await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
+ expect(posted()).toEqual([
+ { type: "WEIGHT", value: 95.25, measuredAt: NOW },
+ ]);
+ });
+
+ it("reads a decimal comma for a metric reader", async () => {
+ const { screen, save, onSuccess } = await mount({ defaultType: "WEIGHT" });
+ const field = screen.getByLabelText("Value (kg)");
+ await expect.element(field).toHaveAttribute("placeholder", "75.5");
+ await field.fill("75,5");
+ await save.click();
+ await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
+ expect(posted()).toEqual([
+ { type: "WEIGHT", value: 75.5, measuredAt: NOW },
+ ]);
+ });
+
+ it("asks in mg/dL for an account that never chose a glucose unit", async () => {
+ const { screen, save, onSuccess } = await mount({
+ defaultType: "BLOOD_GLUCOSE",
+ });
+ const field = screen.getByLabelText("Value (mg/dL)");
+ await expect.element(field).toHaveAttribute("placeholder", "95");
+ await field.fill("104");
+ await save.click();
+ await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
+ expect(posted()).toEqual([
+ {
+ type: "BLOOD_GLUCOSE",
+ value: 104,
+ measuredAt: NOW,
+ glucoseContext: "FASTING",
+ },
+ ]);
+ });
+
+ it("asks a mmol/L reader in mmol/L, stores mg/dL and sends the meal context", async () => {
+ const { screen, save, onSuccess } = await mount(
+ { defaultType: "BLOOD_GLUCOSE" },
+ { glucoseUnit: "mmol/L" },
+ );
+ const field = screen.getByLabelText("Value (mmol/L)");
+ await expect.element(field).toHaveAttribute("placeholder", "5.3");
+ await field.fill("5.5");
+ await screen.getByRole("combobox", { name: /^Context/ }).click();
+ await screen
+ .getByRole("option", { name: "Post-meal (2h after eating)" })
+ .click();
+ await save.click();
+ await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
+ expect(posted()).toEqual([
+ {
+ type: "BLOOD_GLUCOSE",
+ value: 99,
+ measuredAt: NOW,
+ glucoseContext: "POSTPRANDIAL",
+ },
+ ]);
+ });
+
+ it("reads a sleep typed as hours and minutes and stores minutes", async () => {
+ const { screen, save, onSuccess } = await mount({
+ defaultType: "SLEEP_DURATION",
+ });
+ await screen.getByLabelText("Value (hours)").fill("7:30");
+ await expect.element(screen.getByText("Read as 7 h 30 min")).toBeVisible();
+ await save.click();
+ await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
+ expect(posted()).toEqual([
+ { type: "SLEEP_DURATION", value: 450, measuredAt: NOW },
+ ]);
+ });
+
+ it("refuses a duration it cannot read, under the field", async () => {
+ const { screen, save } = await mount({ defaultType: "SLEEP_DURATION" });
+ const field = screen.getByLabelText("Value (hours)");
+ await field.fill("most of the night");
+ await save.click();
+ await expect
+ .element(screen.getByText("Enter a duration like 7.5, 7:30 or 7h 30m."))
+ .toBeVisible();
+ await expect.element(field).toHaveAttribute("aria-invalid", "true");
+ expect(api.requests).toEqual([]);
+ await field.fill("7.5");
+ await expect
+ .element(screen.getByText("Enter a duration like 7.5, 7:30 or 7h 30m."))
+ .not.toBeInTheDocument();
+ });
+});
+
+describe(" type", () => {
+ it("relabels the value field when the type changes", async () => {
+ const { screen } = await mount({ defaultType: "WEIGHT" });
+ await screen.getByRole("combobox", { name: /^Type/ }).click();
+ await screen.getByRole("option", { name: "Body temperature" }).click();
+ await expect.element(screen.getByLabelText("Value (°C)")).toBeVisible();
+ await screen.getByRole("combobox", { name: /^Type/ }).click();
+ await screen.getByRole("option", { name: "Blood Pressure" }).click();
+ await expect.element(screen.getByLabelText(/^Systolic/)).toBeVisible();
+ });
+
+ it("does not open on, or offer, a type whose module is off", async () => {
+ const { screen } = await mount(
+ { defaultType: "BLOOD_GLUCOSE" },
+ { modules: { glucose: false } },
+ );
+ const type = screen.getByRole("combobox", { name: /^Type/ });
+ await expect.element(type).toHaveTextContent("Blood Pressure");
+ await type.click();
+ await expect
+ .element(screen.getByRole("option", { name: "Weight" }))
+ .toBeVisible();
+ await expect
+ .element(screen.getByRole("option", { name: "Blood glucose" }))
+ .not.toBeInTheDocument();
+ await userEvent.keyboard("{Escape}");
+ });
+
+ it("opens on the type saved last when no type is asked for", async () => {
+ const first = await mount({ defaultType: "WEIGHT" });
+ await first.screen.getByLabelText("Value (kg)").fill("80");
+ await first.save.click();
+ await expect.poll(() => first.onSuccess.mock.calls.length).toBe(1);
+ first.screen.unmount();
+
+ const { screen } = await mount();
+ await expect
+ .element(screen.getByRole("combobox", { name: /^Type/ }))
+ .toHaveTextContent("Weight");
+ });
+
+ it("clears every field from the menu's Reset", async () => {
+ const { screen } = await mount({ defaultType: "BLOOD_PRESSURE" });
+ await screen.getByLabelText(/^Systolic/).fill("120");
+ await screen.getByLabelText(/^Notes/).fill("left arm");
+ await screen.getByRole("button", { name: "More options" }).click();
+ await screen.getByRole("menuitem", { name: "Reset form" }).click();
+ await expect.element(screen.getByLabelText(/^Systolic/)).toHaveValue(null);
+ await expect.element(screen.getByLabelText(/^Notes/)).toHaveValue("");
+ });
+});
diff --git a/tests/browser/clock.ts b/tests/browser/clock.ts
new file mode 100644
index 000000000..b1ffd8b5f
--- /dev/null
+++ b/tests/browser/clock.ts
@@ -0,0 +1,14 @@
+/**
+ * Pin `Date` for a browser test, and nothing else.
+ *
+ * Forms default their timestamp to "now" and refuse a future one, so a test
+ * that asserts the instant a form sends has to say what now is. Only `Date`
+ * is faked: timers, animation frames and the microtask queue keep running,
+ * which is what Radix, TanStack Query and the URL-state queue wait on.
+ * `setup.ts` restores the real clock after every test.
+ */
+import { vi } from "vitest";
+
+export function pinDate(iso: string): void {
+ vi.useFakeTimers({ toFake: ["Date"], now: new Date(iso) });
+}
diff --git a/tests/browser/fetch-stub.ts b/tests/browser/fetch-stub.ts
index de5a8cfbc..5bcbe4fd2 100644
--- a/tests/browser/fetch-stub.ts
+++ b/tests/browser/fetch-stub.ts
@@ -14,6 +14,11 @@
* state. A request no route matches fails with a 404 envelope and is recorded
* too, so an unexpected call is visible rather than hanging.
*
+ * One request is ambient: `I18nProvider` mirrors the active locale to
+ * `PUT /api/auth/me/locale` on every mount, as it does in the app. The stub
+ * answers it and leaves it out of `requests`, so "nothing was sent" means
+ * nothing the component under test sent.
+ *
* `setup.ts` restores the real `fetch` after every test.
*/
export interface RecordedRequest {
@@ -30,6 +35,8 @@ export interface StubResponse {
/** The envelope's `data`. Ignored when `error` is set. */
data?: unknown;
error?: string;
+ /** The envelope's `meta` (an error's `errorCode`, a list's totals). */
+ meta?: Record;
}
type Responder = (
@@ -65,6 +72,9 @@ export interface FetchStub {
let realFetch: typeof fetch | null = null;
+/** Sent by the providers on every mount, not by the component under test. */
+const AMBIENT = [{ method: "PUT", path: "/api/auth/me/locale" }];
+
function matches(route: Route, method: string, path: string): boolean {
if (route.method !== method) return false;
const bare = path.split("?")[0];
@@ -73,8 +83,11 @@ function matches(route: Route, method: string, path: string): boolean {
: route.path.test(path);
}
-function envelope({ status = 200, data = null, error }: StubResponse) {
- const body = error ? { data: null, error } : { data, error: null };
+function envelope({ status = 200, data = null, error, meta }: StubResponse) {
+ const body = {
+ ...(error ? { data: null, error } : { data, error: null }),
+ ...(meta ? { meta } : {}),
+ };
return new Response(JSON.stringify(body), {
status: error && status === 200 ? 400 : status,
headers: { "content-type": "application/json" },
@@ -110,6 +123,10 @@ export function stubFetch(): FetchStub {
window.fetch = async (input, init) => {
const request = await record(input, init);
+ const ambient = AMBIENT.some(
+ (a) => a.method === request.method && a.path === request.path,
+ );
+ if (ambient) return envelope({ data: request.body ?? null });
requests.push(request);
// The newest route wins, so a test can override a default it set up.
const route = [...routes]
diff --git a/tests/browser/setup.ts b/tests/browser/setup.ts
index d1ea7821f..2a6647028 100644
--- a/tests/browser/setup.ts
+++ b/tests/browser/setup.ts
@@ -8,13 +8,14 @@
* - The EN catalog, the fallback floor of every `t()` chain, loaded through
* the production loader. `renderWithApp` loads any other locale it is
* asked for.
- * - After every test: the real `fetch` and the page's starting address come
- * back, so a stub or a URL write never reaches the next test. Rendered
+ * - After every test: the real `fetch`, the real clock, the page's starting
+ * address and an empty `localStorage` come back, so a stub, a pinned date,
+ * a URL write or a remembered preference never reaches the next test. Rendered
* trees are unmounted by `vitest-browser-react` itself.
*/
import "@/app/globals.css";
-import { afterEach, beforeAll, beforeEach } from "vitest";
+import { afterEach, beforeAll, beforeEach, vi } from "vitest";
import { loadMessages } from "@/lib/i18n/load-locale";
@@ -33,5 +34,7 @@ beforeEach(() => {
afterEach(() => {
restoreFetch();
+ vi.useRealTimers();
restoreAddress();
+ window.localStorage.clear();
});