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([]); }); });