diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts new file mode 100644 index 000000000..5f2e445e6 --- /dev/null +++ b/e2e/dashboard.spec.ts @@ -0,0 +1,183 @@ +import { expect, test } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * Authenticated dashboard render — proves the seed user can reach the + * home dashboard and the core widgets paint without uncaught client + * errors. + * + * The dashboard self-gates each tile on (visible AND has data), so a + * fresh seed user with zero measurements would render an empty strip + * and skip all charts. Rather than seed measurements before this spec + * runs (which couples the spec to a particular signature of the + * `/api/measurements` shape), we mock `/api/analytics` and + * `/api/dashboard/widgets` to return a deterministic dataset. The + * widget layout + chart wrappers are pure enough that asserting on the + * mocked-data render exercises the same code paths as a real session. + */ +test.describe("authenticated dashboard render", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page }) => { + // Mock analytics with a populated weight summary so the weight tile + // and chart render. `count` must be >0 for the tile to clear the + // data-floor gate in `src/app/page.tsx`. + await page.route("**/api/analytics", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + summaries: { + WEIGHT: { + latest: 78.5, + avg7: 78.2, + avg30: 77.9, + slope30: { slope: -0.05, direction: "down" }, + count: 30, + }, + BLOOD_PRESSURE_SYS: { + latest: 124, + avg7: 122, + avg30: 121, + slope30: { slope: 0.1, direction: "flat" }, + count: 25, + }, + BLOOD_PRESSURE_DIA: { + latest: 80, + avg7: 79, + avg30: 78, + slope30: { slope: 0.05, direction: "flat" }, + count: 25, + }, + PULSE: { + latest: 68, + avg7: 70, + avg30: 71, + slope30: { slope: -0.2, direction: "down" }, + count: 25, + }, + }, + bpInTargetPct: 78, + glucoseByContext: {}, + }, + error: null, + }), + }), + ); + + await page.route("**/api/mood/analytics", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { entries: [], summary: { count: 0 } }, + error: null, + }), + }), + ); + + // The chart's per-type fetch reads `json.data.measurements` and + // expects a `meta.total`. Return a small dataset so the chart has + // points to plot — otherwise Recharts mounts a placeholder instead + // of the recharts-wrapper SVG and the assertion below times out. + await page.route("**/api/measurements*", (route) => { + const url = new URL(route.request().url()); + const type = url.searchParams.get("type") ?? "WEIGHT"; + const baseValue = + type === "BLOOD_PRESSURE_SYS" + ? 124 + : type === "BLOOD_PRESSURE_DIA" + ? 80 + : type === "PULSE" + ? 70 + : 78.5; + const measurements = Array.from({ length: 10 }, (_, i) => ({ + id: `m_${type}_${i}`, + type, + value: baseValue + (i % 3) - 1, + measuredAt: new Date(Date.now() - i * 86_400_000).toISOString(), + notes: null, + })); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { measurements, meta: { total: measurements.length } }, + error: null, + }), + }); + }); + + // The general-status insight card on /insights drives the "AI insight + // card" assertion. We mock its endpoint with a connected, populated + // payload so the card resolves to a real text body instead of the + // "Connect ChatGPT to enable" empty-state. + await page.route("**/api/insights/general-status*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + hasProvider: true, + text: "Your blood-pressure trend is stable. Continue current routine.", + cached: false, + updatedAt: new Date().toISOString(), + }, + error: null, + }), + }), + ); + }); + + test("dashboard tile strip + chart + insight card render", async ({ + page, + }) => { + const consoleErrors: string[] = []; + page.on("pageerror", (err) => { + consoleErrors.push(err.message); + }); + page.on("console", (msg) => { + if (msg.type() === "error") consoleErrors.push(msg.text()); + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + + // Tile strip — `data-slot="dashboard-tile-strip"` is the stable hook + // the dashboard exposes. The component sets `data-tile-count` to the + // active tile count; assert it's >0 so we know at least one tile + // cleared the visibility + data-floor gate. + const strip = page.locator('[data-slot="dashboard-tile-strip"]'); + await expect(strip).toBeVisible({ timeout: 10_000 }); + const tileCount = Number(await strip.getAttribute("data-tile-count")); + expect(tileCount).toBeGreaterThan(0); + + // At least one chart must render. Recharts mounts a `` with + // `class="recharts-surface"` (or a `
` + // around it). Use the recharts class so we don't accidentally match + // an aria-hidden lucide-react icon. + await expect( + page.locator(".recharts-wrapper, .recharts-surface").first(), + ).toBeVisible({ timeout: 10_000 }); + + // AI insight card: visit the dedicated /insights page (which is the + // only surface that mounts the general-status card by default). + await page.goto("/insights", { waitUntil: "domcontentloaded" }); + await expect( + page.getByText("Your blood-pressure trend is stable", { exact: false }), + ).toBeVisible({ timeout: 10_000 }); + + // Filter out a small set of known-noise messages — we care about + // real React/JS uncaught errors only. 404s from optional assets + // (manifest icons, prefetched chunks for unrendered routes) are + // explicitly tolerated; they don't affect the dashboard render. + const significant = consoleErrors.filter( + (msg) => + !/ResizeObserver loop|Download the React DevTools|Warning: |\[Fast Refresh\]|Failed to load resource|net::ERR_/i.test( + msg, + ), + ); + expect(significant, significant.join("\n")).toEqual([]); + }); +}); diff --git a/e2e/measurement-flow.spec.ts b/e2e/measurement-flow.spec.ts new file mode 100644 index 000000000..12493d915 --- /dev/null +++ b/e2e/measurement-flow.spec.ts @@ -0,0 +1,161 @@ +import { expect, test } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * Add-measurement flow — exercises the dashboard's quick-entry dropdown, + * the dialog-mounted MeasurementForm, the POST to /api/measurements, and + * the resulting query invalidation that surfaces the row on + * `/measurements`. + * + * Weight is the simplest type (single numeric input + datetime), so it + * keeps the spec focused on the round-trip rather than the BP three-field + * batched submit. + */ +test.describe("add measurement flow", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("creating a weight reading surfaces it in the list", async ({ + page, + }) => { + // Capture the POST payload so we can assert against it independently + // of any list-fetch response Playwright might cache. Match both + // `/api/measurements` (POST creates a reading) and the list query + // `/api/measurements?type=...&limit=...&offset=...` (GET). + let postedBody: unknown = null; + await page.route("**/api/measurements*", async (route) => { + const req = route.request(); + if (req.method() === "POST") { + postedBody = JSON.parse(req.postData() ?? "null"); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { id: "m_e2e_1", success: true }, + error: null, + }), + }); + return; + } + // GET — return the just-posted reading so the list view shows it. + // The list expects `data.measurements: Measurement[]` plus + // `data.meta.total`; chart fetches use the same envelope. + const body = postedBody as { + type?: string; + value?: number; + measuredAt?: string; + } | null; + const measurements = body + ? [ + { + id: "m_e2e_1", + type: body.type, + value: body.value, + measuredAt: body.measuredAt, + notes: null, + }, + ] + : []; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { measurements, meta: { total: measurements.length } }, + error: null, + }), + }); + }); + + // Dashboard's analytics fetch — empty summaries are fine; we only + // need the page to render so we can click "Add". + await page.route("**/api/analytics", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} }, + error: null, + }), + }), + ); + await page.route("**/api/mood/analytics", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { entries: [], summary: { count: 0 } }, + error: null, + }), + }), + ); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle"); + + // Open the "Add" dropdown — the dashboard's quick-entry trigger sits + // at the top-right of `
`. Scope the locator there so we don't + // accidentally match an "Add" button on the sidebar. + const main = page.locator("main"); + await main + .getByRole("button", { name: /^add$|hinzufügen|hinzufuegen/i }) + .first() + .click(); + + // The Radix DropdownMenu mounts its content in a portal; the role + // is `menuitem`. Both items currently render the literal "Add" in + // EN (measurements.addMeasurement = "Add", mood.addEntry = "Add"), + // so we disambiguate by hitting the FIRST one — that's the + // measurement entry per the dashboard JSX order. Sub-optimal, but + // robust against the i18n collision we don't want to fix in this + // phase (would touch dashboard copy). + await page.getByRole("menuitem").first().click(); + + // Form should be visible inside the dialog. Switch type to WEIGHT + // (combo-box defaults to BLOOD_PRESSURE). + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + + // The Select trigger renders the current label — click to open and + // pick the weight option. + await dialog.getByRole("combobox").first().click(); + await page + .getByRole("option", { name: /weight|gewicht/i }) + .first() + .click(); + + // Weight is single-value mode — fill the value field with 78.4 kg. + const valueInput = dialog.locator("#value"); + await valueInput.fill("78.4"); + + // Save + await dialog.getByRole("button", { name: /save|speichern/i }).click(); + + // Dialog closes when the mutation succeeds + await expect(dialog).toBeHidden({ timeout: 5_000 }); + + // Assert the POST went through with the expected shape + expect(postedBody).toMatchObject({ + type: "WEIGHT", + value: 78.4, + }); + + // Navigate to the list and confirm the entry surfaces. The list + // page renders entries from /api/measurements which we've mocked + // to echo the just-posted row. + await page.goto("/measurements", { waitUntil: "domcontentloaded" }); + + // The list renders a desktop table AND a mobile card grid (one is + // hidden via `md:hidden`/`hidden md:block`). At least one match + // means our row landed in the DOM regardless of viewport. + await expect + .poll( + async () => + await page + .locator("main") + .getByText(/78[.,]4/) + .count(), + { timeout: 10_000 }, + ) + .toBeGreaterThanOrEqual(1); + }); +});