diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index 6821cba0d..18e2a30b2 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -11,6 +11,7 @@ is about choosing and writing tests. | Pure logic: statistics, time zones, cadence, parsers, unit conversion, formatting | `src/**/__tests__/*.test.ts` (unit) | `pnpm test` | | Laws of that logic over random inputs | `*.property.test.ts` next to the unit tests | `pnpm test` | | What a component renders for a given state | `src/**/__tests__/*.test.tsx` (unit) | `pnpm test` | +| How a component behaves when someone types, clicks or presses keys, and what it sends | `src/**/__tests__/*.browser.test.tsx` | `pnpm test` | | A structural rule about the tree (a guard) | `src/__tests__/` with a stated floor | `pnpm test` | | A route: auth, scope, validation, what it stores and what it answers | `tests/integration/routes/` with the kit | `pnpm test:integration` | | Anything whose truth lives in Postgres: constraints, `where` semantics, transactions, locks, cascades, time zones in SQL, backups, jobs | `tests/integration/` | `pnpm test:integration` | @@ -136,6 +137,43 @@ into a fresh account and reads it back there. A new setting column without a table entry fails the file. A column with no write route says why in its entry. +## Browser tests + +Interaction belongs in the `browser` project: `*.browser.test.tsx` files run +in headless Chromium through Vitest's Playwright provider, as part of +`pnpm test`. Run them alone with `pnpm test --project browser`, or one file +with `pnpm exec vitest run --project browser `. The Node projects never +collect these files and this project collects nothing else. CI installs the +browser it needs (`playwright install --only-shell chromium`); locally, run +that once. + +Reach for it when the claim is about behaviour a markup test cannot see: +focus, keys, an open menu, a pending state, the body a form posts, the URL a +filter writes. A `renderToStaticMarkup` grep stays the tool for what a given +state renders, and bottom-sheet gestures stay in `e2e/`. + +The helper is `tests/browser/`: + +- `renderWithApp(ui, { locale, user })` mounts the app's own providers: a real + TanStack `QueryClient` (no retries) and `I18nProvider` with the real + catalog. The signed-in account is seeded where `useAuth` reads it, with + `authUser()` defaults under `user`. The app stylesheet and the default + theme class are loaded, so layout, visibility and colour tokens resolve. +- `stubFetch()` replaces `fetch` and nothing above it. Answer a route with + `.on(method, path, { status, data, error, meta })`, hold one with + `.deferred(...)` to assert a pending state, and read the parsed bodies from + `.calls(method, path)`. An unmatched request gets a 404 envelope and is + recorded. Do not mock `@tanstack/react-query` or `@/lib/api/api-fetch`. +- `next/navigation` is aliased to a shim over the page's real `location` and + `history`; `url.ts` sets the starting query, records pushes and replaces, + and simulates Back or an edited address. +- `pinDate(iso)` fakes `Date` only, so timers and animation frames still run. + +Every test gets the real `fetch`, clock, address and an empty `localStorage` +back afterwards. Locate by role and accessible name, the way a person finds a +control; a `Label` adds a colon to its name, so match a labelled combobox with +`/^Type/` rather than `"Type"`. + ## The integration database Global setup boots one Postgres container and migrates one template database. @@ -174,7 +212,8 @@ node scripts/test-durations.mjs unit unit.json The nightly workflow (`.github/workflows/test-nightly.yml`) uploads a file regenerated from CI timings, which is the better source. -The unit suite is three projects, decided per file from its source +The unit suite is three Node projects and the `browser` project above. The +Node ones are decided per file from its source (`tests/unit-projects.ts`): `guards` and `unit` share a module graph within a worker, `isolated` gives each file a fresh one. A file lands in `isolated` when it mocks, spies, stubs env or globals, fakes timers or resets modules. If a diff --git a/docs/contributing/ui-standards.md b/docs/contributing/ui-standards.md index ee32c5dd8..fe3074434 100644 --- a/docs/contributing/ui-standards.md +++ b/docs/contributing/ui-standards.md @@ -101,7 +101,7 @@ No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring s | A sheet from the bottom edge (phones) | `` or `ResponsiveSheet`, both of which render `BottomSheetContent` (`ui/bottom-sheet.tsx`): grip, swipe to close, X on the title row. `preview="58dvh"` for a sheet that opens part-way and pulls up to 90 dvh (its footer `data-sheet-footer`, its scroll body `data-sheet-body`); `draggable={false}` only for a sheet that fills the window | a hand-rolled grab handle or drag; a `fixed bottom-0` panel standing in for a sheet | | Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) | | Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`): tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined ` +`), + ).toBe(2); + expect(pressedByValue(``)).toBe(0); + }); +}); diff --git a/src/components/ui/__tests__/segmented.browser.test.tsx b/src/components/ui/__tests__/segmented.browser.test.tsx new file mode 100644 index 000000000..d4bf150c0 --- /dev/null +++ b/src/components/ui/__tests__/segmented.browser.test.tsx @@ -0,0 +1,229 @@ +import { useState } from "react"; +import { LayoutGrid, Table } from "lucide-react"; +import { describe, expect, it, vi } from "vitest"; +import { userEvent } from "vitest/browser"; + +import { renderWithApp } from "../../../../tests/browser/render"; +import { Segmented, type SegmentedProps } from "../segmented"; + +type Zoom = "day" | "week" | "month"; + +const OPTIONS = [ + { value: "day", label: "Day" }, + { value: "week", label: "Week" }, + { value: "month", label: "Month" }, +] as const; + +/** + * The control as a caller holds it: the value lives in the parent and moves + * only when `onChange` says so, which is what makes the roving tab stop + * follow the selection. + */ +function Controlled({ + initial = "day", + onChange, + ...props +}: Partial, "value">> & { initial?: Zoom }) { + const [value, setValue] = useState(initial); + return ( + <> + + { + onChange?.(next); + setValue(next); + }} + /> + + + ); +} + +async function mount(props: Parameters[0] = {}) { + const onChange = vi.fn(); + const { screen } = await renderWithApp( + , + ); + const radio = (name: string) => screen.getByRole("radio", { name }); + return { screen, onChange, radio }; +} + +describe(" in radio mode", () => { + it("is one tab stop, on the chosen segment", async () => { + const { screen, radio } = await mount({ initial: "week" }); + await expect + .element(screen.getByRole("radiogroup", { name: "Zoom" })) + .toBeInTheDocument(); + await expect.element(radio("Week")).toBeChecked(); + await expect.element(radio("Day")).not.toBeChecked(); + + await screen.getByRole("button", { name: "Before" }).click(); + await userEvent.tab(); + await expect.element(radio("Week")).toHaveFocus(); + await userEvent.tab(); + await expect + .element(screen.getByRole("button", { name: "After" })) + .toHaveFocus(); + }); + + it("moves focus and selection together with the arrow keys", async () => { + const { onChange, radio } = await mount(); + await radio("Day").click(); + onChange.mockClear(); + + await userEvent.keyboard("{ArrowRight}"); + await expect.element(radio("Week")).toHaveFocus(); + await expect.element(radio("Week")).toBeChecked(); + await userEvent.keyboard("{ArrowDown}"); + await expect.element(radio("Month")).toBeChecked(); + await userEvent.keyboard("{ArrowLeft}"); + await expect.element(radio("Week")).toHaveFocus(); + expect(onChange.mock.calls).toEqual([["week"], ["month"], ["week"]]); + }); + + it("wraps at both ends", async () => { + const { radio } = await mount({ initial: "month" }); + await radio("Month").click(); + await userEvent.keyboard("{ArrowRight}"); + await expect.element(radio("Day")).toHaveFocus(); + await expect.element(radio("Day")).toBeChecked(); + await userEvent.keyboard("{ArrowUp}"); + await expect.element(radio("Month")).toHaveFocus(); + await expect.element(radio("Month")).toBeChecked(); + }); + + it("jumps to the first and last segment with Home and End", async () => { + const { onChange, radio } = await mount({ initial: "week" }); + await radio("Week").click(); + await userEvent.keyboard("{End}"); + await expect.element(radio("Month")).toHaveFocus(); + await userEvent.keyboard("{Home}"); + await expect.element(radio("Day")).toHaveFocus(); + await expect.element(radio("Day")).toBeChecked(); + expect(onChange).toHaveBeenLastCalledWith("day"); + }); + + it("fires again on the chosen segment, which can open something", async () => { + const { onChange, radio } = await mount({ initial: "day" }); + await radio("Day").click(); + await radio("Day").click(); + expect(onChange.mock.calls).toEqual([["day"], ["day"]]); + }); + + it("takes no press and no key while disabled", async () => { + const { screen, onChange, radio } = await mount({ disabled: true }); + await expect + .element(screen.getByRole("radiogroup", { name: "Zoom" })) + .toHaveAttribute("aria-disabled", "true"); + await expect.element(radio("Week")).toBeDisabled(); + await radio("Week").click({ force: true }); + await screen.getByRole("button", { name: "Before" }).click(); + await userEvent.tab(); + await userEvent.keyboard("{ArrowRight}{End}"); + expect(onChange).not.toHaveBeenCalled(); + await expect.element(radio("Day")).toBeChecked(); + }); +}); + +describe(" in toggle mode", () => { + it("renders pressed buttons and ignores a press on the active one", async () => { + const { screen, onChange } = await mount({ mode: "toggle" }); + await expect + .element(screen.getByRole("group", { name: "Zoom" })) + .toBeInTheDocument(); + expect(screen.getByRole("radio").elements()).toHaveLength(0); + const day = screen.getByRole("button", { name: "Day" }); + const week = screen.getByRole("button", { name: "Week" }); + await expect.element(day).toHaveAttribute("aria-pressed", "true"); + + await day.click(); + expect(onChange).not.toHaveBeenCalled(); + await week.click(); + expect(onChange).toHaveBeenCalledExactlyOnceWith("week"); + await expect.element(week).toHaveAttribute("aria-pressed", "true"); + await expect.element(day).toHaveAttribute("aria-pressed", "false"); + }); + + it("leaves every segment its own tab stop and the arrows alone", async () => { + const { screen, onChange } = await mount({ mode: "toggle" }); + await screen.getByRole("button", { name: "Day" }).click(); + await userEvent.keyboard("{ArrowRight}"); + expect(onChange).not.toHaveBeenCalled(); + await userEvent.tab(); + await expect + .element(screen.getByRole("button", { name: "Week" })) + .toHaveFocus(); + }); +}); + +describe(" look", () => { + it("fills only the chosen segment in the primary colour, on no track", async () => { + const { screen, radio } = await mount({ initial: "week" }); + const group = screen.getByRole("radiogroup").element(); + const probe = document.createElement("span"); + probe.className = "bg-primary"; + document.body.append(probe); + const primary = getComputedStyle(probe).backgroundColor; + probe.remove(); + + const background = (name: string) => + getComputedStyle(radio(name).element()).backgroundColor; + expect(background("Week")).toBe(primary); + expect(background("Day")).not.toBe(primary); + expect(getComputedStyle(group).backgroundColor).toBe("rgba(0, 0, 0, 0)"); + }); + + it("sets the small size in smaller type and keeps the 36 px floor", async () => { + const { radio } = await mount(); + const md = radio("Day").element(); + expect(getComputedStyle(md).fontSize).toBe("14px"); + expect(md.getBoundingClientRect().height).toBeGreaterThanOrEqual(36); + + const { screen } = await renderWithApp( + {}} + label="Range" + size="sm" + />, + ); + const sm = screen + .getByRole("radiogroup", { name: "Range" }) + .getByRole("radio", { name: "Day" }) + .element(); + expect(getComputedStyle(sm).fontSize).toBe("12px"); + expect(sm.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); + }); + + it("names icon-only segments without showing the label", async () => { + const onChange = vi.fn(); + const { screen } = await renderWithApp( + `medications-view-${v}`} + />, + ); + const table = screen.getByRole("button", { name: "Table" }); + await expect.element(table).toHaveAttribute("title", "Table"); + await expect + .element(table) + .toHaveAttribute("data-slot", "medications-view-table"); + await expect.element(screen.getByText("Cards")).not.toBeInTheDocument(); + await table.click(); + expect(onChange).toHaveBeenCalledWith("table"); + }); +}); diff --git a/src/components/ui/__tests__/segmented.test.tsx b/src/components/ui/__tests__/segmented.test.tsx deleted file mode 100644 index 7f3770b4c..000000000 --- a/src/components/ui/__tests__/segmented.test.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; -import { isValidElement, type ReactElement, type ReactNode } from "react"; -import { LayoutGrid, Table } from "lucide-react"; - -import { Segmented, type SegmentedProps } from "../segmented"; - -const OPTIONS = [ - { value: "recent", label: "Last 90 days" }, - { value: "all", label: "All" }, -] as const; - -type Props = SegmentedProps<"recent" | "all">; - -function html(props: Partial = {}) { - return renderToStaticMarkup( - {}} - label="History" - slot="history-view" - {...props} - />, - ); -} - -/** The segment buttons of a rendered tree, for driving their handlers. */ -function buttons(node: ReactNode): ReactElement<{ onClick: () => void }>[] { - const out: ReactElement<{ onClick: () => void }>[] = []; - const walk = (n: ReactNode) => { - if (Array.isArray(n)) return n.forEach(walk); - if (!isValidElement(n)) return; - const el = n as ReactElement<{ children?: ReactNode; onClick: () => void }>; - if (el.type === "button") out.push(el); - walk(el.props.children); - }; - walk(node); - return out; -} - -vi.mock("@/hooks/use-roving-radio-group", () => ({ - useRovingRadioGroup: () => ({ - getRadioProps: (i: number) => ({ tabIndex: i === 0 ? 0 : -1 }), - }), -})); - -describe("", () => { - it("is a radiogroup with one checked radio by default", () => { - const out = html(); - expect(out).toContain('role="radiogroup"'); - expect(out).toContain('aria-label="History"'); - expect(out.match(/role="radio"/g)?.length).toBe(2); - expect(out.match(/aria-checked="true"/g)?.length).toBe(1); - expect(out).toContain('data-slot="history-view-all"'); - }); - - it("gives every segment the 44 px floor on a coarse pointer", () => { - const out = html(); - expect(out.match(/pointer-coarse:min-h-11/g)?.length).toBe(2); - expect(out).toContain("min-h-9"); - }); - - it("fills the chosen segment in the primary colour, on no track", () => { - const out = html(); - const segments = out.match(/]*>/g) ?? []; - expect(segments).toHaveLength(2); - const chosen = segments.filter((b) => b.includes('aria-checked="true"')); - expect(chosen).toHaveLength(1); - expect(chosen[0]).toContain("bg-primary text-primary-foreground"); - const other = segments.find((b) => b.includes('aria-checked="false"')); - expect(other).not.toContain("bg-primary"); - const group = out.match(/]*role="radiogroup"[^>]*>/)?.[0]; - expect(group).not.toContain("bg-muted"); - }); - - it("sets a chart header's range row in the small type", () => { - expect(html()).toContain("text-sm"); - const small = html({ size: "sm" }); - expect(small).toContain("text-xs"); - expect(small).not.toContain("text-sm"); - expect(small.match(/min-h-11/g)?.length).toBe(2); - }); - - it("keeps a disabled chosen segment on the readable token pair", () => { - const chosen = html({ disabled: true }) - .match(/]*>/g) - ?.find((b) => b.includes('aria-checked="true"')); - expect(chosen).toContain("disabled:bg-primary-disabled"); - expect(chosen).toContain("disabled:opacity-100"); - }); - - it("renders pressed buttons in toggle mode", () => { - const out = html({ mode: "toggle" }); - expect(out).toContain('role="group"'); - expect(out).not.toContain('role="radio"'); - expect(out).toContain('aria-pressed="true"'); - expect(out).toContain('aria-pressed="false"'); - }); - - it("names icon-only segments and sizes them square", () => { - const out = renderToStaticMarkup( - {}} - label="View" - optionSlot={(v) => `medications-view-${v}`} - />, - ); - expect(out).toContain('aria-label="Cards"'); - expect(out).toContain('title="Table"'); - expect(out.match(/pointer-coarse:size-11/g)?.length).toBe(2); - expect(out).toContain('data-slot="medications-view-table"'); - expect(out).not.toContain(">Cards<"); - }); - - it("re-fires the chosen segment in radio mode, which can open something", () => { - const onChange = vi.fn(); - const tree = Segmented({ - options: OPTIONS, - value: "recent", - onChange, - label: "History", - }); - buttons(tree)[0].props.onClick(); - expect(onChange).toHaveBeenCalledWith("recent"); - }); - - it("ignores a press on the active segment in toggle mode", () => { - const onChange = vi.fn(); - const tree = Segmented({ - options: OPTIONS, - value: "recent", - onChange, - label: "History", - mode: "toggle", - }); - const [active, other] = buttons(tree); - active.props.onClick(); - expect(onChange).not.toHaveBeenCalled(); - other.props.onClick(); - expect(onChange).toHaveBeenCalledWith("all"); - }); -}); - -describe("one segmented control", () => { - it("is the only segmented-control component in the tree", async () => { - const { readFileSync } = await import("node:fs"); - const { join } = await import("node:path"); - const { walkSourceFiles } = - await import("@/__tests__/helpers/source-files"); - const src = join(__dirname, "../../.."); - const defining = walkSourceFiles(src, { floor: 2000 }) - .filter((p) => !p.includes("__tests__/")) - .filter((p) => - /export function (?:Segmented\w*|ViewToggle|\w*SegmentedToggle)\b/.test( - readFileSync(join(src, p), "utf8"), - ), - ); - expect(defining).toEqual(["components/ui/segmented.tsx"]); - }); -}); - -/** - * A row of buttons whose `aria-pressed` compares a state to each button's - * value is a segmented control spelled by hand: six of them sat beside - * `Segmented` (the narrative period, the lab result type, both admin filters, - * the chart range tabs, the mood chart's copy of them), each with its own - * size, fill and gap. New ones are refused; the ones that remain are not one - * choice on one track, and say why. - */ -describe("no hand-built segmented control", () => { - const PRESSED_BY_VALUE = /aria-pressed=\{[^}]*===[^}]*\}/g; - - const NOT_A_SEGMENT: Record = { - "components/settings/security-section/passkey-list-section.tsx": - "one method is disabled while the account has no passkey; Segmented disables the whole track", - "components/medications/units-per-dose-field.tsx": - "a chip row that opens a free-number field for any other count", - "components/body-sites/body-sites-section.tsx": - "wrapping side chips under a body-site picker, not a track", - "components/illness/log-day-sheet.tsx": - "a graded severity scale per symptom, one square per level", - "components/cycle/log-day-sheet.tsx": - "a graded severity scale per symptom, one circle per level", - "components/charts/chart-overlay-controls.tsx": - "baselines stay pressable while greyed, with a tooltip saying why there is no data", - "components/timeline/timeline-view.tsx": - "a single button that opens the range sheet, beside the zoom Segmented", - }; - - function pressedByValue(source: string): number { - return (source.match(PRESSED_BY_VALUE) ?? []).length; - } - - it("finds none outside the reasoned exceptions", async () => { - const { readFileSync } = await import("node:fs"); - const { join } = await import("node:path"); - const { stripComments, walkSourceFiles } = - await import("@/__tests__/helpers/source-files"); - const src = join(__dirname, "../../.."); - const found = walkSourceFiles(src, { floor: 2000 }) - .filter((p) => /^(components|app)\//.test(p)) - .filter((p) => !p.includes("__tests__/")) - .filter((p) => p !== "components/ui/segmented.tsx") - .filter( - (p) => - pressedByValue(stripComments(readFileSync(join(src, p), "utf8"))) > 0, - ) - .sort(); - expect(found).toEqual(Object.keys(NOT_A_SEGMENT).sort()); - }); - - it("counter-check: catches a hand-built pair, not an on/off toggle", () => { - expect( - pressedByValue(` -`), - ).toBe(2); - expect(pressedByValue(``)).toBe(0); - }); -}); diff --git a/src/components/workouts/__tests__/manual-workout-form.browser.test.tsx b/src/components/workouts/__tests__/manual-workout-form.browser.test.tsx new file mode 100644 index 000000000..305a63e6d --- /dev/null +++ b/src/components/workouts/__tests__/manual-workout-form.browser.test.tsx @@ -0,0 +1,205 @@ +/** + * ``, the quick-entry sheet's workout form, driven by + * picking, typing and pressing Save. The entry rules live in + * `manual-entry.ts` and its own suite; this one pins what the person sees + * and what reaches `POST /api/workouts/batch`: the field errors, the distance + * asked in the reader's unit and sent in metres, the start counted back from + * now, the pending state, and one external id per opened form. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; + +import type { AuthUser } from "@/hooks/use-auth"; +import { manualWorkoutOriginalFromRow } from "@/lib/workouts/manual-entry"; + +import { pinDate } from "../../../../tests/browser/clock"; +import { + stubFetch, + type FetchStub, +} from "../../../../tests/browser/fetch-stub"; +import { renderWithApp } from "../../../../tests/browser/render"; +import { ManualWorkoutForm } from "../manual-workout-form"; + +const NOW = "2026-09-15T10:00:00.000Z"; +const BATCH = "/api/workouts/batch"; + +let api: FetchStub; + +beforeEach(() => { + pinDate(NOW); + api = stubFetch().on("POST", BATCH, { + data: { entries: [{ index: 0, status: "inserted" }] }, + }); +}); + +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" }); + const pickSport = async (name: string) => { + await screen.getByRole("combobox", { name: /^Activity/ }).click(); + await screen.getByRole("option", { name }).click(); + }; + return { screen, onSuccess, save, pickSport }; +} + +type Posted = { workouts: Array> }; +const posted = () => api.calls("POST", BATCH).map((r) => r.body as Posted); + +describe("", () => { + it("names what is missing under each field and sends nothing", async () => { + const { screen, save } = await mount(); + await save.click(); + await expect.element(screen.getByText("Choose an activity.")).toBeVisible(); + await expect + .element(screen.getByText("Enter how long the workout lasted.")) + .toBeVisible(); + await expect + .element(screen.getByLabelText("Hours")) + .toHaveAttribute("aria-invalid", "true"); + expect(api.requests).toEqual([]); + }); + + it("asks an imperial reader for miles and sends metres, counted back from now", async () => { + const { screen, save, onSuccess, pickSport } = await mount( + {}, + { unitPreference: "imperial" }, + ); + await pickSport("Running"); + await screen.getByLabelText("Hours").fill("1"); + await screen.getByLabelText("Minutes").fill("30"); + await screen.getByLabelText("Distance (mi)").fill("10"); + await screen.getByLabelText("Active energy (kcal)").fill("640"); + await save.click(); + + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + const [body] = posted(); + expect(body.workouts).toHaveLength(1); + expect(body.workouts[0]).toEqual({ + sportType: "running", + startedAt: "2026-09-15T08:30:00.000Z", + endedAt: NOW, + source: "MANUAL", + externalId: expect.stringMatching(/^manual:/), + totalDistanceM: 16093.4, + totalEnergyKcal: 640, + }); + }); + + it("asks a metric reader for kilometres", async () => { + const { screen, save, onSuccess, pickSport } = await mount(); + await pickSport("Cycling"); + await screen.getByLabelText("Minutes").fill("45"); + await screen.getByLabelText("Distance (km)").fill("21,5"); + await save.click(); + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + expect(posted()[0].workouts[0]).toMatchObject({ + sportType: "cycling", + startedAt: "2026-09-15T09:15:00.000Z", + totalDistanceM: 21500, + }); + }); + + it("holds Save while the write is pending, then reports it once", async () => { + const pending = api.deferred("POST", BATCH); + const { screen, save, onSuccess, pickSport } = await mount(); + await pickSport("Yoga"); + await screen.getByLabelText("Minutes").fill("20"); + await save.click(); + + await expect.element(save).toBeDisabled(); + await expect + .element(screen.getByRole("button", { name: "Cancel" })) + .toBeDisabled(); + pending.release({ data: { entries: [{ index: 0, status: "inserted" }] } }); + await expect.element(save).toBeEnabled(); + expect(onSuccess).toHaveBeenCalledOnce(); + }); + + it("retries a failed save under the same external id", async () => { + api.on("POST", BATCH, { status: 503, error: "Service unavailable" }); + const { screen, save, onSuccess, pickSport } = await mount(); + await pickSport("Running"); + await screen.getByLabelText("Minutes").fill("30"); + await save.click(); + await expect + .element(screen.getByRole("alert")) + .toHaveTextContent("Service unavailable"); + expect(onSuccess).not.toHaveBeenCalled(); + + api.on("POST", BATCH, { + data: { entries: [{ index: 0, status: "duplicate" }] }, + }); + await save.click(); + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + const [first, second] = posted(); + expect(second.workouts[0].externalId).toBe(first.workouts[0].externalId); + }); + + it("lays out for a phone: duration side by side, distance over energy", async () => { + await page.viewport(390, 844); + const { screen, save } = await mount(); + const top = (label: string) => + screen.getByLabelText(label).element().getBoundingClientRect().top; + expect(top("Hours")).toBe(top("Minutes")); + expect(top("Active energy (kcal)")).toBeGreaterThan(top("Distance (km)")); + expect( + save.element().getBoundingClientRect().height, + ).toBeGreaterThanOrEqual(44); + // A record, not a training log: no heart rate, sets or route to fill in. + await expect + .element(screen.getByText(/heart|bpm|sets|reps/i)) + .not.toBeInTheDocument(); + }); + + it("opens an edit on the stored values and re-posts them unchanged", async () => { + const original = manualWorkoutOriginalFromRow( + { + sportType: "cycling", + startedAt: "2026-09-14T06:00:00.000Z", + endedAt: "2026-09-14T07:30:00.000Z", + durationSec: 5400, + distanceM: 32000, + activeEnergyKcal: 640, + minHr: null, + stepCount: null, + elevationM: null, + pauseDurationSec: null, + storedAvgHr: 131, + storedMaxHr: null, + }, + { timezone: "UTC", unitPreference: "metric" }, + ); + const externalId = "manual:5b0f3c1e-1111-4222-8333-444455556666"; + api.on("POST", BATCH, { + data: { entries: [{ index: 0, status: "updated" }] }, + }); + const { screen, save, onSuccess } = await mount({ + edit: { externalId, original }, + }); + await expect + .element(screen.getByRole("combobox", { name: /^Activity/ })) + .toHaveTextContent("Cycling"); + await expect.element(screen.getByLabelText("Hours")).toHaveValue("1"); + await expect.element(screen.getByLabelText("Minutes")).toHaveValue("30"); + await expect + .element(screen.getByLabelText("Distance (km)")) + .toHaveValue("32"); + await save.click(); + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + expect(posted()[0].workouts[0]).toMatchObject({ + externalId, + startedAt: "2026-09-14T06:00:00.000Z", + endedAt: "2026-09-14T07:30:00.000Z", + totalDistanceM: 32000, + totalEnergyKcal: 640, + avgHeartRate: 131, + }); + }); +}); diff --git a/src/components/workouts/__tests__/manual-workout-form.test.tsx b/src/components/workouts/__tests__/manual-workout-save.test.ts similarity index 57% rename from src/components/workouts/__tests__/manual-workout-form.test.tsx rename to src/components/workouts/__tests__/manual-workout-save.test.ts index ff947bbf6..e04b69829 100644 --- a/src/components/workouts/__tests__/manual-workout-form.test.tsx +++ b/src/components/workouts/__tests__/manual-workout-save.test.ts @@ -1,56 +1,28 @@ /** - * `` and its save path, and the detail page's delete. + * The manual workout save path, and the detail page's delete. * - * SSR-only, like the other capture-form suites (vitest runs in `node`, no - * testing-library). The rules themselves live in `manual-entry.ts` and are - * pinned in its own suite; this one pins the wire and the markup: + * The rules themselves live in `manual-entry.ts` and are pinned in its own + * suite, and the mounted form (fields, units, layout, editing) is driven in + * `manual-workout-form.browser.test.tsx`. This one pins the save and delete + * helpers on their own: * * 1. the save posts ONE entry to `POST /api/workouts/batch`, MANUAL, with * the form's external id, and refreshes the workout reads; * 2. a `duplicate` (a second tap) is a success, a `skipped` entry is not; - * 3. the form renders its five fields, the distance in the reader's unit, - * and a layout that stacks distance / energy on a phone; - * 4. delete is offered for a hand-entered workout in one's own record only, + * 3. delete is offered for a hand-entered workout in one's own record only, * and calls `DELETE /api/workouts/{id}`. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; import type { QueryClient } from "@tanstack/react-query"; -import { I18nProvider } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -vi.mock("@tanstack/react-query", () => ({ - useQueryClient: () => ({ invalidateQueries: vi.fn() }), -})); -vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); - -const authRef: { unitPreference: "metric" | "imperial" } = { - unitPreference: "metric", -}; -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { - username: "tester", - timezone: "Europe/Berlin", - unitPreference: authRef.unitPreference, - }, - }), -})); - -import { - ManualWorkoutForm, - saveManualWorkout, - sportOptions, -} from "../manual-workout-form"; +import { saveManualWorkout, sportOptions } from "../manual-workout-form"; import { canDeleteWorkout, deleteManualWorkout, } from "../delete-workout-button"; -import { - manualWorkoutOriginalFromRow, - type ManualWorkoutEntry, -} from "@/lib/workouts/manual-entry"; +import type { ManualWorkoutEntry } from "@/lib/workouts/manual-entry"; const ENTRY: ManualWorkoutEntry = { sportType: "cycling", @@ -75,16 +47,9 @@ function fakeClient() { }; } -function render(node: React.ReactElement) { - return renderToStaticMarkup( - {node}, - ); -} - let fetchSpy: ReturnType; beforeEach(() => { - authRef.unitPreference = "metric"; fetchSpy = vi.spyOn(globalThis, "fetch"); }); @@ -176,43 +141,6 @@ describe("sportOptions", () => { }); }); -describe(" — markup", () => { - it("renders the five fields and nothing else", () => { - const html = render(); - expect(html).toContain('data-testid="manual-workout-sport"'); - expect(html).toContain('data-testid="manual-workout-start"'); - expect(html).toContain('data-testid="manual-workout-hours"'); - expect(html).toContain('data-testid="manual-workout-minutes"'); - expect(html).toContain('data-testid="manual-workout-distance"'); - expect(html).toContain('data-testid="manual-workout-energy"'); - expect(html).toContain("Activity"); - expect(html).toContain("Duration"); - expect(html).toContain("Active energy (kcal)"); - // No heart rate, no sets, no route: a record, not a training log. - expect(html).not.toMatch(/heart|bpm|sets|reps/i); - }); - - it("names the distance in kilometres for a metric reader", () => { - expect(render()).toContain("Distance (km)"); - }); - - it("names the distance in miles for an imperial reader", () => { - authRef.unitPreference = "imperial"; - expect(render()).toContain("Distance (mi)"); - }); - - it("keeps hours and minutes side by side and stacks distance and energy on a phone", () => { - const html = render(); - // Two short numeric fields fit a 390 px sheet side by side. - expect(html).toMatch(/class="grid grid-cols-2 gap-4"/); - // Distance and energy carry longer labels: one column below `sm`, two - // from 640 px up. - expect(html).toMatch(/class="grid gap-4 sm:grid-cols-2"/); - // The action buttons keep the 44 px touch floor on a phone. - expect(html).toContain("min-h-11 sm:min-h-9"); - }); -}); - describe("delete — a hand-entered workout in one's own record only", () => { it("is offered for a MANUAL workout in one's own record", () => { expect( @@ -254,39 +182,3 @@ describe("delete — a hand-entered workout in one's own record only", () => { }); }); }); - -describe(" — editing a stored workout (#1162)", () => { - const original = manualWorkoutOriginalFromRow( - { - sportType: "cycling", - startedAt: "2026-09-15T06:00:00.000Z", - endedAt: "2026-09-15T07:30:00.000Z", - durationSec: 5400, - distanceM: 32000, - activeEnergyKcal: 640, - minHr: null, - stepCount: null, - elevationM: null, - pauseDurationSec: null, - storedAvgHr: 131, - storedMaxHr: null, - }, - { timezone: "Europe/Berlin", unitPreference: "metric" }, - ); - - it("opens on the stored values", () => { - const html = render( - , - ); - expect(html).toContain('value="2026-09-15T08:00"'); - expect(html).toContain('value="1"'); - expect(html).toContain('value="30"'); - expect(html).toContain('value="32"'); - expect(html).toContain('value="640"'); - }); - - it("opens empty when it is not editing", () => { - const html = render(); - expect(html).not.toContain('value="640"'); - }); -}); diff --git a/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx b/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx new file mode 100644 index 000000000..6897b0b2e --- /dev/null +++ b/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx @@ -0,0 +1,268 @@ +/** + * The URL-owned filter contract, driven the way a list page uses it: a + * `FilterBar` of pills over `useUrlFilterSync`, in a real browser with a real + * address bar. What these pin and the markup tests could not: a facet picked + * from the open menu lands in the query as a new history entry, typed input + * rewrites the current one, the per-pill clear and Reset leave a bare URL, two + * writes in a row both land, and Back or an edited address re-seeds the pills. + */ +import { describe, expect, it } from "vitest"; +import { userEvent } from "vitest/browser"; + +import { + FilterBar, + FilterBarDateRange, + FilterBarSelect, +} from "@/components/ui/filter-bar"; +import { Input } from "@/components/ui/input"; + +import { renderWithApp } from "../../../tests/browser/render"; +import { + currentSearch, + navigateExternally, + recordHistory, + setSearch, +} from "../../../tests/browser/url"; +import { useUrlFilterSync } from "../use-url-filter-sync"; + +interface Filters { + type?: string; + source?: string; + q?: string; + from?: string; + to?: string; +} + +const KEYS = ["type", "source", "q", "from", "to"] as const; + +function parse(params: URLSearchParams): Filters { + const filters: Filters = {}; + for (const key of KEYS) { + const value = params.get(key); + if (value) filters[key] = value; + } + return filters; +} + +function serialise(filters: Filters): string { + const sp = new URLSearchParams(); + for (const key of KEYS) { + const value = filters[key]; + if (value) sp.set(key, value); + } + return sp.toString(); +} + +const TYPES = [ + { value: "WEIGHT", label: "Weight" }, + { value: "PULSE", label: "Pulse" }, +]; +const SOURCES = [ + { value: "MANUAL", label: "Manual" }, + { value: "WITHINGS", label: "Withings" }, +]; + +/** A list page's filter rail, as the measurements and mood lists build it. */ +function FilterRail({ syncToUrl = true }: { syncToUrl?: boolean }) { + const { filters, applyFilters, patchFilters } = useUrlFilterSync({ + parse, + serialise, + syncToUrl, + }); + const isFiltered = Object.keys(filters).length > 0; + return ( + <> + patchFilters({ q: e.target.value }, "replace")} + /> + applyFilters({})} + count={`${Object.keys(filters).length} active`} + > + patchFilters({ from })} + onToChange={(to) => patchFilters({ to })} + idPrefix="probe" + /> + + patchFilters({ type: v === "ALL" ? undefined : v }) + } + allLabel="All types" + options={TYPES} + /> + + patchFilters({ source: v === "ALL" ? undefined : v }) + } + allLabel="All sources" + options={SOURCES} + /> + + + ); +} + +async function mount(search = "", props: { syncToUrl?: boolean } = {}) { + setSearch(search); + const history = recordHistory(); + const { screen } = await renderWithApp(); + const pill = (name: string | RegExp) => + screen.getByRole("combobox", { name }); + const reset = () => screen.getByRole("button", { name: "Reset" }); + const pick = async (pillName: string | RegExp, option: string) => { + await pill(pillName).click(); + await screen.getByRole("option", { name: option }).click(); + }; + return { screen, history, pill, reset, pick }; +} + +/** The URL settles a task after the write; wait for the expected query. */ +async function expectSearch(search: string) { + await expect.poll(currentSearch).toBe(search); +} + +describe("useUrlFilterSync with FilterBar", () => { + it("seeds the pills from the address it mounts on", async () => { + const { screen, pill, reset } = await mount("?type=WEIGHT&source=WITHINGS"); + await expect + .element(pill("Type: Weight")) + .toHaveAttribute("data-active", "true"); + await expect.element(pill("Source: Withings")).toBeInTheDocument(); + await expect.element(reset()).toBeVisible(); + await expect.element(screen.getByText("2 active")).toBeVisible(); + }); + + it("shows no Reset and no clear button while unfiltered", async () => { + const { screen, pill } = await mount(); + await expect.element(pill("Type")).toHaveAttribute("data-active", "false"); + await expect + .element(screen.getByRole("button", { name: "Reset" })) + .not.toBeInTheDocument(); + await expect + .element(screen.getByRole("button", { name: /^Clear filter/ })) + .not.toBeInTheDocument(); + }); + + it("writes a facet picked from the open menu as a new history entry", async () => { + const { pill, history, pick } = await mount(); + const entries = window.history.length; + await pick("Type", "Pulse"); + await expectSearch("?type=PULSE"); + expect(history.pushes()).toEqual(["?type=PULSE"]); + expect(history.replaces()).toEqual([]); + expect(window.history.length).toBe(entries + 1); + await expect.element(pill("Type: Pulse")).toBeInTheDocument(); + }); + + it("opens the menu inside the viewport, under its pill", async () => { + const { screen, pill } = await mount(); + // Read before opening: the open menu hides the rest of the page from + // the accessibility tree, the pill included. + const trigger = pill("Source").element().getBoundingClientRect(); + await pill("Source").click(); + const listbox = screen.getByRole("listbox"); + await expect.element(listbox).toBeVisible(); + // The menu slides in from above its resting place; measure where it rests. + await Promise.all(document.getAnimations().map((a) => a.finished)); + const menu = listbox.element().getBoundingClientRect(); + expect(menu.top).toBeGreaterThanOrEqual(trigger.bottom - 1); + expect(menu.bottom).toBeLessThanOrEqual(window.innerHeight); + expect(menu.left).toBeGreaterThanOrEqual(0); + await userEvent.keyboard("{Escape}"); + await expect.element(listbox).not.toBeInTheDocument(); + }); + + it("rewrites the current entry for typed input instead of stacking one per key", async () => { + const { screen, history } = await mount("?type=WEIGHT"); + const entries = window.history.length; + await screen.getByRole("textbox", { name: "Search notes" }).fill("knee"); + await expectSearch("?type=WEIGHT&q=knee"); + expect(history.pushes()).toEqual([]); + expect(history.replaces().at(-1)).toBe("?type=WEIGHT&q=knee"); + expect(window.history.length).toBe(entries); + }); + + it("drops one facet with its clear button and keeps the others", async () => { + const { screen, pill } = await mount("?type=WEIGHT&source=MANUAL"); + await screen.getByRole("button", { name: "Clear filter: Type" }).click(); + await expectSearch("?source=MANUAL"); + await expect.element(pill("Type")).toHaveAttribute("data-active", "false"); + await expect.element(pill("Source: Manual")).toBeInTheDocument(); + }); + + it("clears both date bounds in one press, two writes in a row", async () => { + const { screen } = await mount("?from=2026-05-12&to=2026-06-10&type=PULSE"); + const range = screen.getByRole("button", { name: /^Date range: / }); + await expect.element(range).toHaveTextContent("Date range: 05/12–06/10"); + await screen + .getByRole("button", { name: "Clear filter: Date range" }) + .click(); + await expectSearch("?type=PULSE"); + await expect + .element(screen.getByRole("button", { name: "Date range" })) + .toHaveAttribute("data-active", "false"); + }); + + it("resets every facet to a bare URL and hides Reset", async () => { + const { reset, pill, history } = await mount("?type=WEIGHT&source=MANUAL"); + await reset().click(); + await expectSearch(""); + expect(history.pushes()).toEqual([""]); + await expect.element(pill("Type")).toHaveAttribute("data-active", "false"); + await expect + .element(pill("Source")) + .toHaveAttribute("data-active", "false"); + await expect.element(reset()).not.toBeInTheDocument(); + }); + + it("re-seeds from an address changed outside the component", async () => { + const { pill, reset } = await mount("?type=WEIGHT"); + navigateExternally("?source=WITHINGS"); + await expect.element(pill("Source: Withings")).toBeInTheDocument(); + await expect.element(pill("Type")).toHaveAttribute("data-active", "false"); + navigateExternally(""); + await expect.element(reset()).not.toBeInTheDocument(); + }); + + it("steps back through filter states with the browser's Back", async () => { + const { pill, pick } = await mount("?type=WEIGHT"); + await pick("Type: Weight", "Pulse"); + await expectSearch("?type=PULSE"); + window.history.back(); + await expectSearch("?type=WEIGHT"); + await expect.element(pill("Type: Weight")).toBeInTheDocument(); + }); + + it("keeps the state in memory and the address untouched with syncToUrl off", async () => { + const { pill, pick, history } = await mount("?type=WEIGHT", { + syncToUrl: false, + }); + await expect.element(pill("Type")).toHaveAttribute("data-active", "false"); + await pick("Type", "Pulse"); + await expect.element(pill("Type: Pulse")).toBeInTheDocument(); + expect(currentSearch()).toBe("?type=WEIGHT"); + expect(history.writes).toEqual([]); + }); + + it("names Reset and the clear buttons in the reader's language", async () => { + setSearch("?source=MANUAL"); + const { screen } = await renderWithApp(, { locale: "de" }); + await expect + .element(screen.getByRole("button", { name: "Zurücksetzen" })) + .toBeVisible(); + await expect + .element(screen.getByRole("button", { name: "Filter entfernen: Source" })) + .toBeInTheDocument(); + }); +}); diff --git a/src/hooks/__tests__/use-url-filter-sync.test.tsx b/src/hooks/__tests__/use-url-filter-sync.test.tsx deleted file mode 100644 index 81e5a00bd..000000000 --- a/src/hooks/__tests__/use-url-filter-sync.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/** - * The shared URL-owned-filter hook (`use-url-filter-sync.ts`) — the - * documents-vault pattern extracted for the measurements and mood lists. - * Pinned here: mount parses the live query string, a filter change writes - * the serialised form back through the router (push by default, replace on - * request), an empty filter set restores the bare pathname, and - * `syncToUrl: false` detaches the state from the URL entirely. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -let mockSearch = ""; -const pushMock = vi.fn(); -const replaceMock = vi.fn(); -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: pushMock, replace: replaceMock }), - usePathname: () => "/measurements", - useSearchParams: () => new URLSearchParams(mockSearch), -})); - -import { useUrlFilterSync } from "../use-url-filter-sync"; -import { - installUrlStateBrowser, - type UrlStateBrowser, -} from "@/__tests__/helpers/url-state-browser"; - -let browser: UrlStateBrowser = installUrlStateBrowser("/"); -afterEach(() => browser.uninstall()); - -interface ProbeFilters { - type?: string; - source?: string; -} - -function parse(params: URLSearchParams): ProbeFilters { - const filters: ProbeFilters = {}; - const type = params.get("type"); - if (type) filters.type = type; - const source = params.get("source"); - if (source) filters.source = source; - return filters; -} - -function serialise(filters: ProbeFilters): string { - const sp = new URLSearchParams(); - if (filters.type) sp.set("type", filters.type); - if (filters.source) sp.set("source", filters.source); - return sp.toString(); -} - -interface ProbeResult { - filters: ProbeFilters; - applyFilters: (next: ProbeFilters, mode?: "push" | "replace") => void; -} - -function mount(search: string, syncToUrl?: boolean): ProbeResult { - mockSearch = search; - // Ref-shaped capture cell: the render-scoped assignment happens inside a - // closure the control-flow analysis cannot see through. - const captured: { current: ProbeResult | null } = { current: null }; - function Probe() { - captured.current = useUrlFilterSync({ - parse, - serialise, - syncToUrl, - }); - return null; - } - renderToStaticMarkup(); - if (!captured.current) throw new Error("probe did not render"); - // The live address the writes start from (`@/lib/url-state`). - browser.uninstall(); - browser = installUrlStateBrowser( - search ? `/measurements?${search}` : "/measurements", - ); - return captured.current; -} - -beforeEach(() => { - pushMock.mockClear(); - replaceMock.mockClear(); -}); - -describe("useUrlFilterSync", () => { - it("parses the mounted query string into the filter object", () => { - const { filters } = mount("type=WEIGHT&source=WITHINGS"); - expect(filters).toEqual({ type: "WEIGHT", source: "WITHINGS" }); - }); - - it("writes a filter change to the URL via push by default", () => { - const { applyFilters } = mount(""); - applyFilters({ type: "WEIGHT" }); - expect(browser.push).toHaveBeenCalledWith("/measurements?type=WEIGHT"); - expect(browser.replace).not.toHaveBeenCalled(); - }); - - it("writes via replace when asked (high-frequency input)", () => { - const { applyFilters } = mount(""); - applyFilters({ source: "MANUAL" }, "replace"); - expect(browser.replace).toHaveBeenCalledWith("/measurements?source=MANUAL"); - expect(browser.push).not.toHaveBeenCalled(); - }); - - it("restores the bare pathname when every facet clears", () => { - const { applyFilters } = mount("type=WEIGHT"); - applyFilters({}); - expect(browser.push).toHaveBeenCalledWith("/measurements"); - }); - - it("syncToUrl: false ignores the URL and never navigates", () => { - const { filters, applyFilters } = mount("type=WEIGHT", false); - expect(filters).toEqual({}); - applyFilters({ type: "PULSE" }); - expect(browser.push).not.toHaveBeenCalled(); - expect(browser.replace).not.toHaveBeenCalled(); - }); -}); 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 new file mode 100644 index 000000000..5bcbe4fd2 --- /dev/null +++ b/tests/browser/fetch-stub.ts @@ -0,0 +1,175 @@ +/** + * The network boundary for browser tests. + * + * Components talk to the server through `@/lib/api/api-fetch`, which calls + * the global `fetch`. Replacing `fetch` and nothing else keeps everything + * above it real: the envelope unwrapping, the idempotency header, TanStack + * Query's caching and retries, the component's own pending and error states. + * Never mock `@tanstack/react-query` or the API wrapper in this project; a + * test that does asserts its own stand-in. + * + * Every request is recorded with its parsed JSON body, so a form test asserts + * the payload the server would receive. A route can answer later than the + * request (`deferred`), which is how a test holds a form in its pending + * 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 { + method: string; + /** Path and query, without the origin. */ + path: string; + headers: Headers; + /** The parsed JSON body, or the raw text when it is not JSON. */ + body: unknown; +} + +export interface StubResponse { + status?: number; + /** 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 = ( + request: RecordedRequest, +) => StubResponse | Promise; + +interface Route { + method: string; + path: string | RegExp; + respond: Responder; +} + +export interface FetchStub { + /** Answer `method path` (exact path, or a pattern) with `response`. */ + on( + method: string, + path: string | RegExp, + response: StubResponse | Responder, + ): FetchStub; + /** + * Answer `method path` only when `release` is called, for asserting the + * pending state in between. + */ + deferred( + method: string, + path: string | RegExp, + ): { release: (response?: StubResponse) => void }; + /** Every request so far, in order. */ + requests: RecordedRequest[]; + /** The requests that hit `method path`. */ + calls(method: string, path: string | RegExp): RecordedRequest[]; +} + +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]; + return typeof route.path === "string" + ? route.path === bare + : route.path.test(path); +} + +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" }, + }); +} + +async function record( + input: RequestInfo | URL, + init?: RequestInit, +): Promise { + const request = new Request(input, init); + const url = new URL(request.url, window.location.href); + const text = request.method === "GET" ? "" : await request.text(); + let body: unknown = text === "" ? undefined : text; + try { + if (text !== "") body = JSON.parse(text); + } catch { + // Keep the raw text. + } + return { + method: request.method, + path: `${url.pathname}${url.search}`, + headers: request.headers, + body, + }; +} + +/** Replace `fetch` for the rest of the test. */ +export function stubFetch(): FetchStub { + realFetch ??= window.fetch; + const routes: Route[] = []; + const requests: RecordedRequest[] = []; + + 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] + .reverse() + .find((r) => matches(r, request.method, request.path)); + if (!route) { + return envelope({ + status: 404, + error: `no stubbed route for ${request.method} ${request.path}`, + }); + } + return envelope(await route.respond(request)); + }; + + const stub: FetchStub = { + requests, + on(method, path, response) { + routes.push({ + method, + path, + respond: typeof response === "function" ? response : () => response, + }); + return stub; + }, + deferred(method, path) { + let release: (response?: StubResponse) => void = () => {}; + const answer = new Promise((resolve) => { + release = (response = {}) => resolve(response); + }); + routes.push({ method, path, respond: () => answer }); + return { release: (response) => release(response) }; + }, + calls(method, path) { + return requests.filter((r) => + matches({ method, path, respond: () => ({}) }, r.method, r.path), + ); + }, + }; + return stub; +} + +/** Called by `setup.ts` after each test. */ +export function restoreFetch(): void { + if (realFetch) window.fetch = realFetch; + realFetch = null; +} diff --git a/tests/browser/next-navigation.ts b/tests/browser/next-navigation.ts new file mode 100644 index 000000000..4c1084968 --- /dev/null +++ b/tests/browser/next-navigation.ts @@ -0,0 +1,89 @@ +/** + * `next/navigation` for the browser project, aliased in `vitest.config.mts`. + * + * The App Router's hooks throw unless a router is mounted, and mounting one + * means booting Next. What a component needs from them is smaller: the + * current path and query, and a way to change them. This module answers both + * from the page's real `location` and `history`, so `@/lib/url-state` writes + * through the same `pushState` / `replaceState` it uses in the app. + * + * `useSearchParams` follows the address bar the way the router does: it + * re-reads on `popstate` and on the URL-state event, one task after the write + * rather than synchronously, which is the "adopted a render later" gap the + * helper's queue exists for. A test that only asserts what the page shows + * never sees the gap; one that writes twice in a row exercises it. + * + * `redirect` and `notFound` throw like Next's do, so a component that calls + * one fails its test loudly instead of rendering past it. + */ +import { useMemo, useSyncExternalStore } from "react"; + +import { URL_STATE_EVENT } from "@/lib/url-state"; + +function subscribe(onChange: () => void): () => void { + // The router adopts a write after the event loop turns, not inside the + // dispatch that announced it. + const deferred = () => setTimeout(onChange, 0); + window.addEventListener("popstate", deferred); + window.addEventListener(URL_STATE_EVENT, deferred); + return () => { + window.removeEventListener("popstate", deferred); + window.removeEventListener(URL_STATE_EVENT, deferred); + }; +} + +const readSearch = () => window.location.search; +const readPathname = () => window.location.pathname; + +export function useSearchParams(): URLSearchParams { + const search = useSyncExternalStore(subscribe, readSearch, readSearch); + return useMemo(() => new URLSearchParams(search), [search]); +} + +export function usePathname(): string { + return useSyncExternalStore(subscribe, readPathname, readPathname); +} + +function navigate(mode: "push" | "replace", href: string): void { + const url = new URL(href, window.location.href); + if (mode === "push") window.history.pushState(null, "", url); + else window.history.replaceState(null, "", url); + window.dispatchEvent(new PopStateEvent("popstate")); +} + +const router = { + push: (href: string) => navigate("push", href), + replace: (href: string) => navigate("replace", href), + back: () => window.history.back(), + forward: () => window.history.forward(), + refresh: () => {}, + prefetch: () => {}, +}; + +export function useRouter(): typeof router { + return router; +} + +export function useParams(): Record { + return {}; +} + +export function useSelectedLayoutSegment(): string | null { + return null; +} + +export function useSelectedLayoutSegments(): string[] { + return []; +} + +export function redirect(href: string): never { + throw new Error(`redirect(${href}) called in a browser test`); +} + +export function permanentRedirect(href: string): never { + throw new Error(`permanentRedirect(${href}) called in a browser test`); +} + +export function notFound(): never { + throw new Error("notFound() called in a browser test"); +} diff --git a/tests/browser/render.tsx b/tests/browser/render.tsx new file mode 100644 index 000000000..dc692a03d --- /dev/null +++ b/tests/browser/render.tsx @@ -0,0 +1,101 @@ +/** + * 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(, { + * 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 { + 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 | 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 { + const client = queryClient ?? testQueryClient(); + if (user !== null) client.setQueryData(queryKeys.authMe(), authUser(user)); + if (locale !== "en") await loadMessages(locale); + const screen = await render( + + {ui} + , + ); + return { screen, queryClient: client }; +} diff --git a/tests/browser/setup.ts b/tests/browser/setup.ts new file mode 100644 index 000000000..2a6647028 --- /dev/null +++ b/tests/browser/setup.ts @@ -0,0 +1,40 @@ +/** + * Setup for the `browser` project (`*.browser.test.tsx`, headless Chromium). + * + * - The app stylesheet, compiled by Vite through the project's PostCSS + * config, so components lay out and hide as they do in the app, under the + * theme class the boot script sets for a first visit (`dark`, see + * `src/lib/pwa/theme-color.ts`); without one no colour token resolves. + * - 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`, 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, vi } from "vitest"; + +import { loadMessages } from "@/lib/i18n/load-locale"; + +import { restoreFetch } from "./fetch-stub"; +import { rememberAddress, restoreAddress } from "./url"; + +document.documentElement.classList.add("dark"); + +beforeAll(async () => { + await loadMessages("en"); +}); + +beforeEach(() => { + rememberAddress(); +}); + +afterEach(() => { + restoreFetch(); + vi.useRealTimers(); + restoreAddress(); + window.localStorage.clear(); +}); diff --git a/tests/browser/url.ts b/tests/browser/url.ts new file mode 100644 index 000000000..55540b193 --- /dev/null +++ b/tests/browser/url.ts @@ -0,0 +1,89 @@ +/** + * The page's address, for URL-state tests in the browser project. + * + * Each test file runs in its own page, and `setup.ts` puts the address the + * page started on back after every test, so a test may write the URL freely. + * Only the query and hash are ever changed: the path is the test runner's + * own, and a component that writes state keeps the path it found. + */ +import { resetUrlStateForTests } from "@/lib/url-state"; + +export interface HistoryRecord { + /** Every `pushState` and `replaceState` since `recordHistory()`, in order. */ + writes: Array<{ mode: "push" | "replace"; search: string }>; + pushes: () => string[]; + replaces: () => string[]; +} + +let restoreHistory: (() => void) | null = null; + +/** + * Record history writes without changing them: the real methods still run, + * so the page's location moves exactly as it would in the app. + */ +export function recordHistory(): HistoryRecord { + restoreHistory?.(); + const { history } = window; + const push = history.pushState.bind(history); + const replace = history.replaceState.bind(history); + const writes: HistoryRecord["writes"] = []; + const searchOf = (url: string | URL | null | undefined) => + url == null + ? window.location.search + : new URL(url, window.location.href).search; + history.pushState = (data, unused, url) => { + push(data, unused, url); + writes.push({ mode: "push", search: searchOf(url) }); + }; + history.replaceState = (data, unused, url) => { + replace(data, unused, url); + writes.push({ mode: "replace", search: searchOf(url) }); + }; + restoreHistory = () => { + history.pushState = push; + history.replaceState = replace; + restoreHistory = null; + }; + return { + writes, + pushes: () => writes.filter((w) => w.mode === "push").map((w) => w.search), + replaces: () => + writes.filter((w) => w.mode === "replace").map((w) => w.search), + }; +} + +/** Start the test on `search` (`""` for a bare path). */ +export function setSearch(search: string): void { + const query = search === "" || search.startsWith("?") ? search : `?${search}`; + window.history.replaceState(null, "", `${window.location.pathname}${query}`); +} + +/** + * Change the query the way the person would from outside the component: + * Back, Forward or an edited address. The router hears `popstate`. + */ +export function navigateExternally(search: string): void { + setSearch(search); + window.dispatchEvent(new PopStateEvent("popstate")); +} + +/** The current query string, `?`-prefixed or empty. */ +export function currentSearch(): string { + return window.location.search; +} + +let initialHref: string | null = null; + +/** Called by `setup.ts` before each test. */ +export function rememberAddress(): void { + initialHref = window.location.href; +} + +/** Called by `setup.ts` after each test. */ +export function restoreAddress(): void { + restoreHistory?.(); + resetUrlStateForTests(); + if (initialHref !== null && window.location.href !== initialHref) { + window.history.replaceState(null, "", initialHref); + } +} diff --git a/vitest.config.mts b/vitest.config.mts index b62ed7af1..995c34e16 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,3 +1,4 @@ +import { playwright } from "@vitest/browser-playwright"; import { defineConfig } from "vitest/config"; import { globSync } from "node:fs"; import { dirname, resolve } from "node:path"; @@ -10,12 +11,17 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); /** - * Three projects, decided per file from its source (`tests/unit-projects.ts`): - * `guards` and `unit` share a module graph inside a worker (`isolate: - * false`), because their files neither mock nor stub anything; `isolated` - * keeps one fresh graph per file for the ones that do. Run one with - * `pnpm test --project guards`. + * Four projects. Three run in Node and are decided per file from its source + * (`tests/unit-projects.ts`): `guards` and `unit` share a module graph inside + * a worker (`isolate: false`), because their files neither mock nor stub + * anything; `isolated` keeps one fresh graph per file for the ones that do. + * The fourth, `browser`, runs `*.browser.test.tsx` in headless Chromium for + * components whose behaviour is interaction (see `tests/browser/`). Run one + * with `pnpm test --project guards`. */ +/** Files the `browser` project owns; no Node project collects them. */ +const BROWSER_TEST = /\.browser\.test\.tsx$/; + const projectFiles = classifyUnitFiles( __dirname, globSync( @@ -28,28 +34,34 @@ const projectFiles = classifyUnitFiles( { cwd: __dirname, exclude: (path) => - path.startsWith("tests/integration") || path.includes("node_modules"), + path.startsWith("tests/integration") || + path.includes("node_modules") || + BROWSER_TEST.test(path), }, ).sort(), ); +/** What the three Node projects share; the browser project sets its own. */ +const nodeTest = { + globals: true, + environment: "node", + // Pin the host timezone. CI runs UTC; without this a contributor in any + // other zone sees a different suite than the gate does — verified: a + // UTC+14 host fails five tests on an otherwise green tree. Pinning makes + // local and CI agree, and makes "which zone does this test mean?" an + // explicit choice the fixture has to state rather than a property of the + // machine it runs on. Tests that need a different HOST zone set + // `process.env.TZ` themselves and restore it afterwards (see + // `next-due-day-label.test.ts`). + env: { TZ: "UTC" }, + // Primes the i18n locale cache so provider mounts resolve every + // locale synchronously (production gets the active bundle as an RSC + // prop instead — see src/lib/i18n/load-locale.ts). + setupFiles: ["./vitest.setup.ts"], +}; + export default defineConfig({ test: { - globals: true, - environment: "node", - // Pin the host timezone. CI runs UTC; without this a contributor in any - // other zone sees a different suite than the gate does — verified: a - // UTC+14 host fails five tests on an otherwise green tree. Pinning makes - // local and CI agree, and makes "which zone does this test mean?" an - // explicit choice the fixture has to state rather than a property of the - // machine it runs on. Tests that need a different HOST zone set - // `process.env.TZ` themselves and restore it afterwards (see - // `next-due-day-label.test.ts`). - env: { TZ: "UTC" }, - // Primes the i18n locale cache so provider mounts resolve every - // locale synchronously (production gets the active bundle as an RSC - // prop instead — see src/lib/i18n/load-locale.ts). - setupFiles: ["./vitest.setup.ts"], // `--shard` splits by measured duration rather than file count, and files // start longest first. See `tests/durations/sequencer.ts`. sequence: { @@ -77,15 +89,84 @@ export default defineConfig({ projects: [ { extends: true, - test: { name: "guards", include: projectFiles.guards, isolate: false }, + test: { + ...nodeTest, + name: "guards", + include: projectFiles.guards, + isolate: false, + }, }, { extends: true, - test: { name: "unit", include: projectFiles.shared, isolate: false }, + test: { + ...nodeTest, + name: "unit", + include: projectFiles.shared, + isolate: false, + }, }, { extends: true, - test: { name: "isolated", include: projectFiles.isolated }, + test: { + ...nodeTest, + name: "isolated", + include: projectFiles.isolated, + }, + }, + // Not `extends: true` and none of `nodeTest`: the Node setup file reads + // the file system and mocks the write meter. Each file gets its own + // page, so a stubbed `fetch` or a rewritten URL cannot leak into the + // next file. + { + resolve: { + alias: { + "@": resolve(__dirname, "src"), + // The App Router's hooks throw outside a mounted router. The shim + // reads and writes the page's real location instead. + "next/navigation": resolve( + __dirname, + "tests/browser/next-navigation.ts", + ), + }, + }, + // 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 + // importing it; CI starts every run on an empty cache. `force` + // makes a local run start there too: a cache warmed before a new + // browser file existed does not know that file's dependencies, and + // the late re-bundle loads a second React ("Invalid hook call"). + // A cold bundle costs about half a second. + optimizeDeps: { + force: true, + entries: ["src/**/*.browser.test.tsx", "tests/browser/**/*.{ts,tsx}"], + }, + test: { + name: "browser", + include: ["src/**/*.browser.test.tsx"], + globals: true, + setupFiles: ["./tests/browser/setup.ts"], + browser: { + enabled: true, + headless: true, + // Pinned like the Node projects' `TZ`, so a date the form + // derives from the clock means the same instant everywhere. + provider: playwright({ + contextOptions: { timezoneId: "UTC", locale: "en-US" }, + }), + instances: [{ browser: "chromium" }], + screenshotFailures: false, + }, + }, }, ], coverage: {