diff --git a/src/components/mental-health/__tests__/check-in-wizard.browser.test.tsx b/src/components/mental-health/__tests__/check-in-wizard.browser.test.tsx
new file mode 100644
index 000000000..69469cf9f
--- /dev/null
+++ b/src/components/mental-health/__tests__/check-in-wizard.browser.test.tsx
@@ -0,0 +1,331 @@
+/**
+ * The questionnaire check-in, driven by clicks and keys in a real browser.
+ *
+ * The step arithmetic is pinned on its own in `check-in-nav.test.ts`; this
+ * suite proves the wizard a person uses: one question at a time in the source
+ * order, Back keeping the answer, the number keys, the review with a way back
+ * to every answer, the optional PHQ-9 follow-up, the note over an unvalidated
+ * translation staying on screen with every question, and the body the page
+ * posts. The questionnaire text loads through the production chunk loader,
+ * not a seeded cache.
+ */
+import { describe, expect, it, vi } from "vitest";
+import { page, userEvent } from "vitest/browser";
+
+import { WORDING } from "@/lib/mental-health/wording";
+import type { Locale } from "@/lib/i18n/config";
+
+import { stubFetch } from "../../../../tests/browser/fetch-stub";
+import { renderWithApp } from "../../../../tests/browser/render";
+import { CheckInWizard } from "../check-in-wizard";
+import { MentalWellbeing } from "../mental-wellbeing";
+import type { CheckInAnswers, InstrumentId } from "../types";
+
+const WHO5 = WORDING.WHO5.en;
+const PHQ9 = WORDING.PHQ9.en;
+
+async function wizard(
+ instrument: InstrumentId,
+ {
+ locale = "en",
+ isError = false,
+ }: { locale?: Locale; isError?: boolean } = {},
+) {
+ const onSubmit = vi.fn<(answers: CheckInAnswers) => void>();
+ const onBack = vi.fn();
+ const { screen } = await renderWithApp(
+ ,
+ { locale },
+ );
+ const heading = screen.getByRole("heading", { level: 2 });
+ /** The step on screen asks `text` (waiting out the language's chunk). */
+ const expectQuestion = (text: string) =>
+ expect.element(heading).toHaveTextContent(text);
+ const answer = (name: string) =>
+ screen.getByRole("radio", { name, exact: true });
+ const button = (name: string) =>
+ screen.getByRole("button", { name, exact: true });
+ return { screen, onSubmit, onBack, expectQuestion, answer, button };
+}
+
+/**
+ * Answer the current step by its position, as a keyboard user does: each new
+ * step focuses its question, so Tab first reaches the answers, then the
+ * number key chooses.
+ */
+async function pressAnswer(position: number) {
+ await userEvent.tab();
+ await userEvent.keyboard(String(position));
+}
+
+describe(" steps", () => {
+ it("opens on the first question with the stem and the answers in source order", async () => {
+ const { screen, button } = await wizard("WHO5");
+ await expect.element(screen.getByText(WHO5.items[0])).toBeVisible();
+ await expect.element(screen.getByText(WHO5.stems.period)).toBeVisible();
+ await expect.element(screen.getByText("Question 1 of 5")).toBeVisible();
+ const labels = screen
+ .getByRole("radio")
+ .elements()
+ .map((el) => el.textContent);
+ expect(labels).toEqual([5, 4, 3, 2, 1, 0].map((v) => WHO5.anchors.all[v]));
+ await expect.element(button("Back")).toBeDisabled();
+ await expect.element(button("Next")).toBeDisabled();
+ await expect.element(button("See my result")).not.toBeInTheDocument();
+ // The instrument's own explanation heads the check-in; the landing's
+ // self-test disclaimer does not follow the person into it.
+ await expect
+ .element(
+ screen.getByText(
+ "5-question well-being check. Answer for the last two weeks; higher numbers mean better well-being.",
+ ),
+ )
+ .toBeVisible();
+ await expect
+ .element(screen.getByText(/not a diagnosis/i))
+ .not.toBeInTheDocument();
+ // English is the validated wording: no note, no switch.
+ await expect
+ .element(screen.getByText(/has not been scientifically validated/))
+ .not.toBeInTheDocument();
+ });
+
+ it("gives each SCI item its own answers under its section stem", async () => {
+ const SCI = WORDING.SCI.en;
+ const { screen, expectQuestion } = await wizard("SCI");
+ await expectQuestion(SCI.items[0]);
+ await expect.element(screen.getByText(SCI.stems.night)).toBeVisible();
+ await expect.element(screen.getByText("Question 1 of 8")).toBeVisible();
+ expect(
+ screen
+ .getByRole("radio")
+ .elements()
+ .map((el) => el.textContent),
+ ).toEqual([4, 3, 2, 1, 0].map((v) => SCI.anchors.duration[v]));
+ });
+
+ it("advances on a choice, and Back returns with the answer still chosen", async () => {
+ const { screen, answer, button, expectQuestion } = await wizard("WHO5");
+ await answer(WHO5.anchors.all[3]).click();
+ await expect.element(screen.getByText("Question 2 of 5")).toBeVisible();
+ await expectQuestion(WHO5.items[1]);
+ await expect.element(button("Next")).toBeDisabled();
+
+ await button("Back").click();
+ await expectQuestion(WHO5.items[0]);
+ await expect.element(answer(WHO5.anchors.all[3])).toBeChecked();
+ await button("Next").click();
+ await expectQuestion(WHO5.items[1]);
+ });
+
+ it("chooses by number key, moves with the arrows and focuses each new question", async () => {
+ const { screen, answer } = await wizard("WHO5");
+ await answer(WHO5.anchors.all[5]).click();
+ await expect
+ .element(screen.getByRole("heading", { name: WHO5.items[1] }))
+ .toHaveFocus();
+ await userEvent.tab();
+ await expect.element(answer(WHO5.anchors.all[5])).toHaveFocus();
+ await userEvent.keyboard("{ArrowDown}{ArrowDown}");
+ await expect.element(answer(WHO5.anchors.all[3])).toHaveFocus();
+ await userEvent.keyboard("{End}");
+ await expect.element(answer(WHO5.anchors.all[0])).toHaveFocus();
+
+ // "2" is the second answer as printed, which scores 4 on the WHO-5.
+ await userEvent.keyboard("2");
+ await expect.element(screen.getByText("Question 3 of 5")).toBeVisible();
+ await screen.getByRole("button", { name: "Back", exact: true }).click();
+ await expect.element(answer(WHO5.anchors.all[4])).toBeChecked();
+ });
+
+ it("reviews every answer, changes one and returns to the review", async () => {
+ const { screen, onSubmit, button } = await wizard("WHO5");
+ for (const position of [1, 2, 3, 4, 5]) await pressAnswer(position);
+ await expect
+ .element(screen.getByRole("heading", { name: "Check your answers" }))
+ .toBeVisible();
+ await expect
+ .element(screen.getByText("All 5 questions answered"))
+ .toBeVisible();
+
+ await screen
+ .getByRole("button", { name: "Change the answer to question 3" })
+ .click();
+ await expect.element(screen.getByText("Question 3 of 5")).toBeVisible();
+ await screen
+ .getByRole("radio", { name: WHO5.anchors.all[0], exact: true })
+ .click();
+ await expect
+ .element(screen.getByRole("heading", { name: "Check your answers" }))
+ .toBeVisible();
+
+ await button("See my result").click();
+ expect(onSubmit).toHaveBeenCalledExactlyOnceWith({
+ items: [5, 4, 0, 2, 1],
+ functionalDifficulty: undefined,
+ englishOriginal: false,
+ });
+ });
+
+ it("asks the PHQ-9 follow-up as an optional step that a second press clears", async () => {
+ const { screen, onSubmit, answer, button, expectQuestion } =
+ await wizard("PHQ9");
+ for (let i = 0; i < 9; i++) await pressAnswer(1);
+ await expect
+ .element(screen.getByText("Follow-up question (optional)"))
+ .toBeVisible();
+ await expectQuestion(PHQ9.functional!.title);
+ await expect.element(button("Review answers")).toBeEnabled();
+
+ await answer(PHQ9.functional!.anchors[2]).click();
+ await expect.element(answer(PHQ9.functional!.anchors[2])).toBeChecked();
+ await answer(PHQ9.functional!.anchors[2]).click();
+ await expect.element(answer(PHQ9.functional!.anchors[2])).not.toBeChecked();
+
+ await button("Review answers").click();
+ await expect.element(screen.getByText("Not answered")).toBeVisible();
+ await button("See my result").click();
+ expect(onSubmit).toHaveBeenCalledExactlyOnceWith({
+ items: [0, 0, 0, 0, 0, 0, 0, 0, 0],
+ functionalDifficulty: undefined,
+ englishOriginal: false,
+ });
+ });
+
+ it("returns to the overview and shows a failed submit", async () => {
+ const { screen, onBack } = await wizard("GAD7", { isError: true });
+ await expect
+ .element(screen.getByRole("alert"))
+ .toHaveTextContent("Something went wrong. Please try again.");
+ await screen.getByRole("button", { name: "Overview" }).click();
+ expect(onBack).toHaveBeenCalledOnce();
+ });
+});
+
+describe(" in the reader's language", () => {
+ const SCI_DE = WORDING.SCI.de;
+ const NOTE =
+ "Diese Übersetzung ist nicht wissenschaftlich validiert; nimm das Ergebnis als Orientierung.";
+
+ it("keeps the note over an unvalidated translation on screen with every question", async () => {
+ await page.viewport(390, 640);
+ const { screen, expectQuestion } = await wizard("SCI", { locale: "de" });
+ expect(SCI_DE.validated).toBe(false);
+ const note = screen.getByText(NOTE);
+ for (let i = 0; i < 8; i++) {
+ await expectQuestion(SCI_DE.items[i]);
+ await expect.element(note).toBeVisible();
+ const rect = note.element().getBoundingClientRect();
+ expect(rect.top, `step ${i + 1}`).toBeGreaterThanOrEqual(0);
+ expect(rect.bottom, `step ${i + 1}`).toBeLessThanOrEqual(
+ window.innerHeight,
+ );
+ // The first question arrives with the language's chunk, after the
+ // focus move has run, so it is answered by pointer; the rest by key.
+ if (i === 0) await screen.getByRole("radio").first().click();
+ else await pressAnswer(1);
+ }
+ await expect
+ .element(screen.getByRole("heading", { name: "Antworten prüfen" }))
+ .toBeVisible();
+ await expect.element(note).toBeVisible();
+ });
+
+ it("switches to the English original and says so in the answers", async () => {
+ const { screen, onSubmit, expectQuestion } = await wizard("SCI", {
+ locale: "de",
+ });
+ await expectQuestion(SCI_DE.items[0]);
+ await screen
+ .getByRole("button", { name: "Englischen Originaltext zeigen" })
+ .click();
+ await expect
+ .element(screen.getByRole("heading", { name: WORDING.SCI.en.items[0] }))
+ .toHaveAttribute("lang", "en");
+ // The note stays: the reader is still in a language with no validated form.
+ await expect.element(screen.getByText(NOTE)).toBeVisible();
+ await screen.getByRole("radio").first().click();
+ for (let i = 1; i < 8; i++) await pressAnswer(1);
+ await screen
+ .getByRole("button", { name: "Ergebnis ansehen", exact: true })
+ .click();
+ expect(onSubmit).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({ englishOriginal: true }),
+ );
+ });
+
+ it("shows a validated translation with no note and no switch", async () => {
+ const { screen, expectQuestion } = await wizard("PHQ9", { locale: "nl" });
+ await expectQuestion(WORDING.PHQ9.nl.items[0]);
+ await expect
+ .element(screen.getByText(WORDING.PHQ9.nl.stems.period))
+ .toBeVisible();
+ await expect
+ .element(screen.getByRole("button", { name: /English/ }))
+ .not.toBeInTheDocument();
+ });
+});
+
+describe("the check-in from the overview", () => {
+ it("posts the answers with the instrument and the reader's language, then shows the result", async () => {
+ const api = stubFetch().on("GET", "/api/mental-health/assessments", {
+ data: { assessments: [] },
+ });
+ const post = api.deferred("POST", "/api/mental-health/assessments");
+ const { screen } = await renderWithApp();
+
+ const start =
+ '[data-instrument="WHO5"] [data-slot="instrument-card-start"]';
+ await expect.poll(() => document.querySelector(start)).toBeTruthy();
+ await page.elementLocator(document.querySelector(start)!).click();
+ await expect.element(screen.getByText(WHO5.items[0])).toBeVisible();
+ await screen.getByRole("radio").nth(1).click();
+ for (const position of [2, 3, 1, 2]) await pressAnswer(position);
+ const submit = screen.getByRole("button", { name: "See my result" });
+ await submit.click();
+
+ await expect
+ .element(screen.getByRole("button", { name: "Saving…" }))
+ .toBeDisabled();
+ expect(api.calls("POST", "/api/mental-health/assessments")).toHaveLength(1);
+ expect(api.calls("POST", "/api/mental-health/assessments")[0].body).toEqual(
+ {
+ instrument: "WHO5",
+ locale: "en",
+ items: [4, 4, 3, 5, 4],
+ englishOriginal: false,
+ },
+ );
+
+ post.release({
+ status: 201,
+ data: {
+ assessment: {
+ id: "a-1",
+ instrument: "WHO5",
+ locale: "en",
+ wording: "validated",
+ totalScore: 80,
+ severityBand: "good",
+ item9Flagged: false,
+ crisisShownAt: null,
+ takenAt: "2026-09-15T10:00:00.000Z",
+ },
+ actionThreshold: 50,
+ crisis: null,
+ },
+ });
+ await expect
+ .element(screen.getByText("80", { exact: true }).first())
+ .toBeVisible();
+ await expect
+ .element(screen.getByRole("button", { name: "See my result" }))
+ .not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx
index f31aff775..dc9925453 100644
--- a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx
+++ b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx
@@ -6,15 +6,14 @@ import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import en from "../../../../messages/en.json";
import nl from "../../../../messages/nl.json";
-import { WORDING } from "@/lib/mental-health/wording";
import { seedQuestionnaireWording } from "@/__tests__/helpers/questionnaire-wording";
/**
- * Mental-wellbeing surface redesign (v1.25.3). The SSR-only convention
- * (`renderToStaticMarkup`, `environment: "node"`, no `@testing-library/react`)
- * means clicks can't be driven here — the wizard's advance/back grammar is
- * pinned in `check-in-nav.test.ts`. These tests pin the SSR-observable
- * contracts: the disclaimer renders ONLY on the landing (never while testing),
+ * Mental-wellbeing surface redesign (v1.25.3). The check-in wizard itself is
+ * driven in a real browser by `check-in-wizard.browser.test.tsx` (steps,
+ * keys, review, follow-up, the unvalidated-translation note, the posted
+ * body); its step arithmetic is pinned in `check-in-nav.test.ts`. These tests
+ * pin the rendered contracts of the rest of the surface: the disclaimer renders ONLY on the landing (never while testing),
* a positive item-9 surfaces the crisis card, and (v1.27.9) the landing is
* intro + instrument cards only — the dated history + trend chart live in the
* per-instrument detail a card click opens, pinned to one instrument.
@@ -74,52 +73,6 @@ describe("landing chrome (v1.25.5 — disclaimer + heading removed)", () => {
// …and the lead description still introduces the screeners.
expect(html).toContain(mh.pageDescription);
});
-
- it("does NOT render any disclaimer inside the check-in wizard", () => {
- const html = withProviders(
- {}}
- onBack={() => {}}
- isPending={false}
- isError={false}
- />,
- );
- expect(html).not.toContain('data-slot="mental-health-disclaimer"');
- // …but the standardized instrument explanation IS shown.
- expect(html).toContain(mh.instrumentDescription.phq9);
- });
-});
-
-describe("check-in wizard scaffolding (v1.27.6 — questions only)", () => {
- const html = withProviders(
- {}}
- onBack={() => {}}
- isPending={false}
- isError={false}
- />,
- );
-
- it("no longer renders the question-overview strip", () => {
- expect(html).not.toContain('data-slot="wizard-stepper"');
- });
-
- it("no longer renders the review recap or the functional follow-up", () => {
- expect(html).not.toContain("You have answered");
- expect(html).not.toContain('data-slot="check-in-submit"');
- });
-
- it("opens on the first question with the Back/Next footer", () => {
- expect(html).toContain(WORDING.PHQ9.en.items[0]);
- expect(html).toContain('data-slot="check-in-back"');
- expect(html).toContain('data-slot="check-in-next"');
- // Back is disabled on the first step.
- expect(html).toMatch(
- /data-slot="check-in-back"[^>]*disabled|disabled[^>]*data-slot="check-in-back"/,
- );
- });
});
describe("item-9 crisis surfacing on the result", () => {
@@ -243,49 +196,6 @@ describe("WHO-5 / SCI on the same infrastructure (v1.27.9)", () => {
expect(html).not.toContain("Rhythmus");
});
- it("WHO-5 wizard opens on item 1 with the six-point scale in source order and the recall stem", () => {
- const html = withProviders(
- {}}
- onBack={() => {}}
- isPending={false}
- isError={false}
- />,
- );
- expect(html).toContain(WORDING.WHO5.en.items[0]);
- expect(html).toContain('data-slot="check-in-stem"');
- expect(html).toContain(WORDING.WHO5.en.stems.period);
- // Six anchors, 5 → 0 (the WHO form leads with "All of the time").
- for (const v of [5, 4, 3, 2, 1, 0]) {
- expect(html).toContain(WORDING.WHO5.en.anchors.all[v]);
- }
- // English items are the validated wording for en — no translation note.
- expect(html).not.toContain('data-slot="check-in-unvalidated-note"');
- expect(html).not.toContain('data-slot="check-in-wording-toggle"');
- // No functional follow-up outside the PHQ-9: 5 steps total.
- expect(html).toContain("Question 1 of 5");
- });
-
- it("SCI wizard opens on item 1 with the item-specific duration anchors and section stem", () => {
- const html = withProviders(
- {}}
- onBack={() => {}}
- isPending={false}
- isError={false}
- />,
- );
- expect(html).toContain(WORDING.SCI.en.items[0]);
- expect(html).toContain(WORDING.SCI.en.stems.night);
- // Item 1 carries the sleep-latency time ranges, 4 → 0.
- for (const v of [4, 3, 2, 1, 0]) {
- expect(html).toContain(WORDING.SCI.en.anchors.duration[v]);
- }
- expect(html).toContain("Question 1 of 8");
- });
-
it("shows the gentle PHQ-9 pointer on a WHO-5 total of 50 or below", () => {
const result: CreateResponse = {
assessment: {
@@ -530,23 +440,20 @@ describe("questionnaires in the reader's language", () => {
);
}
- function wizard(instrument: "PHQ9" | "SCI") {
- return (
- {}}
- onBack={() => {}}
- isPending={false}
- isError={false}
- />
- );
- }
-
+ // Kept here rather than in the browser suite: the frame before the
+ // language's chunk arrives is the one a server render always paints, and a
+ // browser loads the chunk before a test can look.
it("holds the step's place while the language's text loads", () => {
const html = renderToStaticMarkup(
- {wizard("SCI")}
+ {}}
+ onBack={() => {}}
+ isPending={false}
+ isError={false}
+ />
,
);
@@ -555,30 +462,6 @@ describe("questionnaires in the reader's language", () => {
expect(html).not.toContain('data-slot="check-in-unvalidated-note"');
});
- it("the PHQ-9 in Dutch is the validated Dutch form, with no note and no switch", () => {
- const html = renderIn("nl", wizard("PHQ9"));
- const dutch = WORDING.PHQ9.nl;
- expect(html).toContain(dutch.stems.period);
- expect(html).toContain(dutch.items[0]);
- for (const v of [0, 1, 2, 3]) expect(html).toContain(dutch.anchors.all[v]);
- expect(html).not.toContain(WORDING.PHQ9.en.items[0]);
- expect(html).not.toContain('data-slot="check-in-unvalidated-note"');
- expect(html).not.toContain('data-slot="check-in-wording-toggle"');
- });
-
- it("the SCI in Dutch shows the translation under the note, with the switch to the English original", () => {
- const html = renderIn("nl", wizard("SCI"));
- const dutch = WORDING.SCI.nl;
- expect(dutch.validated).toBe(false);
- expect(html).toContain(dutch.stems.night);
- expect(html).toContain(dutch.items[0]);
- expect(html).not.toContain(WORDING.SCI.en.items[0]);
- expect(html).toContain('data-slot="check-in-unvalidated-note"');
- expect(html).toContain(nl.mentalHealth.wording.unvalidatedNote);
- expect(html).toContain('data-slot="check-in-wording-toggle"');
- expect(html).toContain(nl.mentalHealth.wording.showOriginal);
- });
-
it("the result and the history mark an unvalidated translation, and only that", () => {
const row = (wording: AssessmentRow["wording"]): AssessmentRow => ({
id: `s-${wording}`,
diff --git a/vitest.config.mts b/vitest.config.mts
index 51fa3430b..995c34e16 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -129,6 +129,15 @@ export default defineConfig({
),
},
},
+ // Next's client modules (`next/link` among them) read
+ // `process.env.*`, which Next's own bundler inlines at build time.
+ // A page has no `process`; this inlines an environment with no
+ // public variables set, as a build without any would.
+ define: {
+ "process.env": JSON.stringify({
+ NODE_ENV: process.env.NODE_ENV ?? "test",
+ }),
+ },
// Pre-bundle every dependency the browser files reach before the
// first one loads. Discovered late, a dependency makes Vite re-bundle
// and reload the page mid-run, which fails the file that was