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 /insights mother page gates the InsightAdvisorCard on // `/api/insights/comprehensive` returning a non-null payload // (`totalMeasurements` is the gate). Mock it so the page renders // the card surface instead of the EmptyState CTA. await page.route("**/api/insights/comprehensive", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ data: { summaries: {}, bmi: 24.2, bmiClassification: null, bpClassification: null, bpPctInTarget: 78, bpTargets: { sysLow: 120, sysHigh: 140, diaLow: 80, diaHigh: 90 }, weightBpCorrelation: null, scatterData: [], bpMedicationCorrelation: null, bpMedicationScatterData: [], medications: [], alerts: [], hasOpenAiKey: true, dataSpanDays: 90, totalMeasurements: 25, moodSummary: null, moodBpCorrelation: null, moodBpScatterData: [], moodWeightCorrelation: null, moodWeightScatterData: [], moodPulseCorrelation: null, moodPulseScatterData: [], }, error: null, }), }), ); // InsightAdvisorCard reads `/api/insights/generate` (a POST that // doubles as cache-aware read). Mock a minimal-but-valid // InsightResult so the card renders the populated body branch with // the `data-slot="insight-summary"` container the assertion below // pins on. The exact prose is intentionally NOT asserted — AI // Insights are HealthLog's differentiator and the rendered text // depends on provider + cache state. The contract under test is // "an insight card with a populated body is visible", not "the // string X is on screen". await page.route("**/api/insights/generate", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ data: { insights: { insightType: "general", summary: "Blood-pressure trend is stable; continue routine.", classification: "gut", classificationLabel: "Good", findings: [], correlations: [], primaryRecommendation: "Keep up the current routine.", recommendations: [], dataQuality: { coverage: "good", gaps: [], confidence: "hoch", }, disclaimer: "Not medical advice.", }, cached: true, cachedAt: new Date().toISOString(), legacyPayload: false, }, 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 (the // mother surface that mounts the InsightAdvisorCard). We assert // on the populated-card's stable structural hook // (`data-slot="insight-summary"`) rather than a literal piece of // AI prose. AI Insights are evidence-grounded and dynamic by // design — the rendered text changes per provider + cache state // and never repeats verbatim, so pinning a literal substring is // brittle and contradicts the differentiator. The contract under // test is "the insight card mounts its summary body when the // payload arrives", which is exactly what the slot signals. await page.goto("/insights", { waitUntil: "domcontentloaded" }); await expect( page.locator('[data-slot="insight-summary"]'), ).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([]); }); });