// ABOUTME: E2E tests for dashboard functionality including decision display and overrides. // ABOUTME: Tests both unauthenticated redirect behavior and authenticated dashboard features. import { expect, test } from "@playwright/test"; test.describe("dashboard", () => { test.describe("unauthenticated", () => { test("redirects to login when not authenticated", async ({ page }) => { await page.goto("/"); // Should either redirect to /login or show login link const url = page.url(); const hasLoginInUrl = url.includes("/login"); if (!hasLoginInUrl) { const loginLink = page.getByRole("link", { name: /login|sign in/i }); await expect(loginLink).toBeVisible(); } else { await expect(page).toHaveURL(/\/login/); } }); }); test.describe("authenticated", () => { // These tests require TEST_USER_EMAIL and TEST_USER_PASSWORD env vars // Skip if not available test.beforeEach(async ({ page }) => { const email = process.env.TEST_USER_EMAIL; const password = process.env.TEST_USER_PASSWORD; if (!email || !password) { test.skip(); return; } // Login via the login page await page.goto("/login"); await page.waitForLoadState("networkidle"); // Fill and submit login form const emailInput = page.getByLabel(/email/i); const hasEmailForm = await emailInput.isVisible().catch(() => false); if (!hasEmailForm) { test.skip(); return; } await emailInput.fill(email); await page.getByLabel(/password/i).fill(password); await page.getByRole("button", { name: /sign in/i }).click(); // Wait for redirect to dashboard await page.waitForURL("/", { timeout: 10000 }); }); test("displays dashboard with main sections", async ({ page }) => { // Check for main dashboard elements await expect(page.locator("main")).toBeVisible(); // Look for key dashboard sections by their content const dashboardContent = page.locator("main"); await expect(dashboardContent).toBeVisible(); }); test("shows decision card", async ({ page }) => { // Look for decision-related content const decisionArea = page .locator('[data-testid="decision-card"]') .or( page.getByRole("heading").filter({ hasText: /train|rest|decision/i }), ); // May take time to load await expect(decisionArea) .toBeVisible({ timeout: 10000 }) .catch(() => { // If no specific decision card, check for general dashboard content }); }); test("shows override toggles when user has period data", async ({ page, }) => { // Wait for dashboard data to load await page.waitForLoadState("networkidle"); // Override toggles should be visible if user has period data const overrideCheckbox = page.getByRole("checkbox", { name: /flare mode|high stress|poor sleep|pms/i, }); // These may not be visible if user hasn't set up period date const hasOverrides = await overrideCheckbox .first() .isVisible() .catch(() => false); if (hasOverrides) { await expect(overrideCheckbox.first()).toBeVisible(); } }); test("can toggle override checkboxes", async ({ page }) => { // Wait for the OVERRIDES section to appear (indicates dashboard data loaded) const overridesHeading = page.getByRole("heading", { name: "OVERRIDES" }); const hasOverridesSection = await overridesHeading .waitFor({ timeout: 10000 }) .then(() => true) .catch(() => false); if (!hasOverridesSection) { test.skip(); return; } // Find an override toggle checkbox (Flare Mode, High Stress, etc.) const toggleCheckbox = page .getByRole("checkbox", { name: /flare mode|high stress|poor sleep|pms/i, }) .first(); const hasToggle = await toggleCheckbox.isVisible().catch(() => false); if (hasToggle) { // Get initial state const initialChecked = await toggleCheckbox.isChecked(); // Click the toggle await toggleCheckbox.click(); // Wait a moment for the API call await page.waitForTimeout(500); // Toggle should change state const afterChecked = await toggleCheckbox.isChecked(); expect(afterChecked).not.toBe(initialChecked); } else { test.skip(); } }); test("shows navigation to settings", async ({ page }) => { // Look for settings link const settingsLink = page.getByRole("link", { name: /settings/i }); await expect(settingsLink).toBeVisible(); }); test("shows cycle info when period data is set", async ({ page }) => { // Look for cycle day or phase info const cycleInfo = page.getByText(/cycle day|phase|day \d+/i); const hasCycleInfo = await cycleInfo .first() .isVisible() .catch(() => false); if (!hasCycleInfo) { // User may not have period data - look for onboarding prompt const onboardingPrompt = page.getByText(/period|set up|get started/i); await expect(onboardingPrompt.first()) .toBeVisible() .catch(() => { // Neither cycle info nor onboarding - might be error state }); } }); test("shows mini calendar when period data is set", async ({ page }) => { // Mini calendar should show month/year and days const calendar = page .locator('[data-testid="mini-calendar"]') .or(page.getByRole("grid").filter({ has: page.getByRole("gridcell") })); const hasCalendar = await calendar.isVisible().catch(() => false); // Calendar may not be visible if no period data if (hasCalendar) { await expect(calendar).toBeVisible(); } }); }); test.describe("data panel", () => { // These tests require TEST_USER_EMAIL and TEST_USER_PASSWORD env vars test.beforeEach(async ({ page }) => { const email = process.env.TEST_USER_EMAIL; const password = process.env.TEST_USER_PASSWORD; if (!email || !password) { test.skip(); return; } await page.goto("/login"); await page.waitForLoadState("networkidle"); const emailInput = page.getByLabel(/email/i); const hasEmailForm = await emailInput.isVisible().catch(() => false); if (!hasEmailForm) { test.skip(); return; } await emailInput.fill(email); await page.getByLabel(/password/i).fill(password); await page.getByRole("button", { name: /sign in/i }).click(); await page.waitForURL("/", { timeout: 10000 }); }); test("displays HRV status with label", async ({ page }) => { await page.waitForLoadState("networkidle"); // Wait for data panel to load const dataPanel = page.locator('[data-testid="data-panel"]'); const hasDataPanel = await dataPanel.isVisible().catch(() => false); if (hasDataPanel) { // HRV status should show "Balanced", "Unbalanced", or "Unknown" const hrvText = page.getByText(/HRV Status/i); await expect(hrvText).toBeVisible(); const statusText = page.getByText(/balanced|unbalanced|unknown/i); const hasStatus = await statusText .first() .isVisible() .catch(() => false); expect(hasStatus).toBe(true); } }); test("displays Body Battery current value", async ({ page }) => { await page.waitForLoadState("networkidle"); const dataPanel = page.locator('[data-testid="data-panel"]'); const hasDataPanel = await dataPanel.isVisible().catch(() => false); if (hasDataPanel) { // Body Battery label should be visible const bbLabel = page.getByText(/Body Battery/i).first(); await expect(bbLabel).toBeVisible(); } }); test("displays cycle day in 'Day X' format", async ({ page }) => { await page.waitForLoadState("networkidle"); // Look for "Day" followed by a number const cycleDayText = page.getByText(/Day \d+/i); const hasCycleDay = await cycleDayText .first() .isVisible() .catch(() => false); // Either has cycle day or onboarding (both valid states) if (!hasCycleDay) { const onboarding = page.getByText(/set.*period|log.*period/i); const hasOnboarding = await onboarding .first() .isVisible() .catch(() => false); expect(hasCycleDay || hasOnboarding).toBe(true); } }); test("displays current phase name", async ({ page }) => { await page.waitForLoadState("networkidle"); // Look for phase names const phaseNames = [ "MENSTRUAL", "FOLLICULAR", "OVULATION", "EARLY_LUTEAL", "LATE_LUTEAL", ]; let foundPhase = false; for (const phase of phaseNames) { const phaseText = page.getByText(new RegExp(phase, "i")); const isVisible = await phaseText .first() .isVisible() .catch(() => false); if (isVisible) { foundPhase = true; break; } } // Either has phase or shows onboarding if (!foundPhase) { const onboarding = page.getByText(/set.*period|log.*period/i); const hasOnboarding = await onboarding .first() .isVisible() .catch(() => false); expect(foundPhase || hasOnboarding).toBe(true); } }); test("displays week intensity minutes", async ({ page }) => { await page.waitForLoadState("networkidle"); const dataPanel = page.locator('[data-testid="data-panel"]'); const hasDataPanel = await dataPanel.isVisible().catch(() => false); if (hasDataPanel) { // Look for intensity-related text const intensityLabel = page.getByText(/intensity|minutes/i); const hasIntensity = await intensityLabel .first() .isVisible() .catch(() => false); expect(hasIntensity).toBe(true); } }); test("displays phase limit", async ({ page }) => { await page.waitForLoadState("networkidle"); const dataPanel = page.locator('[data-testid="data-panel"]'); const hasDataPanel = await dataPanel.isVisible().catch(() => false); if (hasDataPanel) { // Phase limit should be shown as a number (minutes) const limitLabel = page.getByText(/limit|remaining/i); const hasLimit = await limitLabel .first() .isVisible() .catch(() => false); expect(hasLimit).toBe(true); } }); test("displays remaining minutes calculation", async ({ page }) => { await page.waitForLoadState("networkidle"); const dataPanel = page.locator('[data-testid="data-panel"]'); const hasDataPanel = await dataPanel.isVisible().catch(() => false); if (hasDataPanel) { // Remaining minutes should show (phase limit - week intensity) const remainingLabel = page.getByText(/remaining/i); const hasRemaining = await remainingLabel .first() .isVisible() .catch(() => false); expect(hasRemaining).toBe(true); } }); }); test.describe("nutrition panel", () => { test.beforeEach(async ({ page }) => { const email = process.env.TEST_USER_EMAIL; const password = process.env.TEST_USER_PASSWORD; if (!email || !password) { test.skip(); return; } await page.goto("/login"); await page.waitForLoadState("networkidle"); const emailInput = page.getByLabel(/email/i); const hasEmailForm = await emailInput.isVisible().catch(() => false); if (!hasEmailForm) { test.skip(); return; } await emailInput.fill(email); await page.getByLabel(/password/i).fill(password); await page.getByRole("button", { name: /sign in/i }).click(); await page.waitForURL("/", { timeout: 10000 }); }); test("displays seed cycling recommendation", async ({ page }) => { await page.waitForLoadState("networkidle"); // Look for seed names (flax, pumpkin, sesame, sunflower) const seedText = page.getByText(/flax|pumpkin|sesame|sunflower/i); const hasSeeds = await seedText .first() .isVisible() .catch(() => false); // Either has seeds info or onboarding if (!hasSeeds) { const onboarding = page.getByText(/set.*period|log.*period/i); const hasOnboarding = await onboarding .first() .isVisible() .catch(() => false); expect(hasSeeds || hasOnboarding).toBe(true); } }); test("displays carbohydrate range", async ({ page }) => { await page.waitForLoadState("networkidle"); // Look for carb-related text const carbText = page.getByText(/carb|carbohydrate/i); const hasCarbs = await carbText .first() .isVisible() .catch(() => false); if (!hasCarbs) { const onboarding = page.getByText(/set.*period|log.*period/i); const hasOnboarding = await onboarding .first() .isVisible() .catch(() => false); expect(hasCarbs || hasOnboarding).toBe(true); } }); test("displays keto guidance", async ({ page }) => { await page.waitForLoadState("networkidle"); // Look for keto-related text const ketoText = page.getByText(/keto/i); const hasKeto = await ketoText .first() .isVisible() .catch(() => false); if (!hasKeto) { const onboarding = page.getByText(/set.*period|log.*period/i); const hasOnboarding = await onboarding .first() .isVisible() .catch(() => false); expect(hasKeto || hasOnboarding).toBe(true); } }); test("displays nutrition section header", async ({ page }) => { await page.waitForLoadState("networkidle"); // Nutrition panel should have a header const nutritionHeader = page.getByRole("heading", { name: /nutrition/i }); const hasHeader = await nutritionHeader.isVisible().catch(() => false); if (!hasHeader) { // May be text label instead of heading const nutritionText = page.getByText(/nutrition/i); const hasText = await nutritionText .first() .isVisible() .catch(() => false); expect(hasHeader || hasText).toBe(true); } }); }); test.describe("accessibility", () => { test.beforeEach(async ({ page }) => { const email = process.env.TEST_USER_EMAIL; const password = process.env.TEST_USER_PASSWORD; if (!email || !password) { test.skip(); return; } await page.goto("/login"); await page.waitForLoadState("networkidle"); const emailInput = page.getByLabel(/email/i); const hasEmailForm = await emailInput.isVisible().catch(() => false); if (!hasEmailForm) { test.skip(); return; } await emailInput.fill(email); await page.getByLabel(/password/i).fill(password); await page.getByRole("button", { name: /sign in/i }).click(); await page.waitForURL("/", { timeout: 10000 }); }); test("dashboard has main landmark", async ({ page }) => { await page.waitForLoadState("networkidle"); const mainElement = page.locator("main"); await expect(mainElement).toBeVisible(); }); test("skip navigation link is available", async ({ page }) => { // Skip link should be present (may be visually hidden until focused) const skipLink = page.getByRole("link", { name: /skip to main/i }); // Check if it exists in DOM even if visually hidden const skipLinkExists = await skipLink.count(); expect(skipLinkExists).toBeGreaterThan(0); }); test("override toggles are keyboard accessible", async ({ page }) => { await page.waitForLoadState("networkidle"); const overridesHeading = page.getByRole("heading", { name: "OVERRIDES" }); const hasOverrides = await overridesHeading .waitFor({ timeout: 10000 }) .then(() => true) .catch(() => false); if (!hasOverrides) { test.skip(); return; } // Find a checkbox const checkbox = page.getByRole("checkbox").first(); const hasCheckbox = await checkbox.isVisible().catch(() => false); if (hasCheckbox) { // Focus should be possible via tab await checkbox.focus(); const isFocused = await checkbox.evaluate( (el) => document.activeElement === el, ); expect(isFocused).toBe(true); } }); test("interactive elements have focus indicators", async ({ page }) => { await page.waitForLoadState("networkidle"); // Find any link or button const interactiveElement = page .getByRole("link") .or(page.getByRole("button")) .first(); const hasInteractive = await interactiveElement .isVisible() .catch(() => false); if (hasInteractive) { // Focus the element await interactiveElement.focus(); // Element should receive focus (we can't easily test visual ring, but focus should work) const isFocused = await interactiveElement.evaluate( (el) => document.activeElement === el, ); expect(isFocused).toBe(true); } }); }); test.describe("error handling", () => { test("handles network errors gracefully", async ({ page }) => { // Intercept API calls and make them fail await page.route("**/api/today", (route) => { route.fulfill({ status: 500, body: JSON.stringify({ error: "Internal server error" }), }); }); // Navigate to dashboard (will redirect to login if not authenticated) await page.goto("/"); // If redirected to login, that's the expected behavior const url = page.url(); if (url.includes("/login")) { await expect(page).toHaveURL(/\/login/); } }); }); test.describe("decision card", () => { test.beforeEach(async ({ page }) => { const email = process.env.TEST_USER_EMAIL; const password = process.env.TEST_USER_PASSWORD; if (!email || !password) { test.skip(); return; } await page.goto("/login"); await page.waitForLoadState("networkidle"); const emailInput = page.getByLabel(/email/i); const hasEmailForm = await emailInput.isVisible().catch(() => false); if (!hasEmailForm) { test.skip(); return; } await emailInput.fill(email); await page.getByLabel(/password/i).fill(password); await page.getByRole("button", { name: /sign in/i }).click(); await page.waitForURL("/", { timeout: 10000 }); }); test("GENTLE status displays with yellow styling", async ({ page }) => { await page.waitForLoadState("networkidle"); // If GENTLE is displayed, verify styling const gentleText = page.getByText("GENTLE"); const hasGentle = await gentleText .first() .isVisible() .catch(() => false); if (hasGentle) { const decisionCard = page.locator('[data-testid="decision-card"]'); const hasCard = await decisionCard.isVisible().catch(() => false); if (hasCard) { const cardClasses = await decisionCard.getAttribute("class"); // Card should have CSS classes for styling expect(cardClasses).toBeTruthy(); } } }); test("LIGHT status displays with appropriate styling", async ({ page }) => { await page.waitForLoadState("networkidle"); // LIGHT status for medium-low Body Battery const lightText = page.getByText("LIGHT"); const hasLight = await lightText .first() .isVisible() .catch(() => false); if (hasLight) { const decisionCard = page.locator('[data-testid="decision-card"]'); const hasCard = await decisionCard.isVisible().catch(() => false); if (hasCard) { const cardClasses = await decisionCard.getAttribute("class"); expect(cardClasses).toBeTruthy(); } } }); test("REDUCED status displays with appropriate styling", async ({ page, }) => { await page.waitForLoadState("networkidle"); // REDUCED status for moderate Body Battery const reducedText = page.getByText("REDUCED"); const hasReduced = await reducedText .first() .isVisible() .catch(() => false); if (hasReduced) { const decisionCard = page.locator('[data-testid="decision-card"]'); const hasCard = await decisionCard.isVisible().catch(() => false); if (hasCard) { const cardClasses = await decisionCard.getAttribute("class"); expect(cardClasses).toBeTruthy(); } } }); test("decision card displays status icon", async ({ page }) => { await page.waitForLoadState("networkidle"); const decisionCard = page.locator('[data-testid="decision-card"]'); const hasCard = await decisionCard.isVisible().catch(() => false); if (hasCard) { // Decision card should contain an SVG icon or emoji representing status const hasIcon = (await decisionCard.locator("svg").count()) > 0 || (await decisionCard.getByRole("img").count()) > 0 || // Or contains common status emojis (await decisionCard.textContent())?.match(/🛑|⚠️|✅|🟡|🟢|💪|😴/); // Should have some visual indicator expect( hasIcon || (await decisionCard.textContent())?.length, ).toBeTruthy(); } }); }); test.describe("override behaviors", () => { test.beforeEach(async ({ page }) => { const email = process.env.TEST_USER_EMAIL; const password = process.env.TEST_USER_PASSWORD; if (!email || !password) { test.skip(); return; } await page.goto("/login"); await page.waitForLoadState("networkidle"); const emailInput = page.getByLabel(/email/i); const hasEmailForm = await emailInput.isVisible().catch(() => false); if (!hasEmailForm) { test.skip(); return; } await emailInput.fill(email); await page.getByLabel(/password/i).fill(password); await page.getByRole("button", { name: /sign in/i }).click(); await page.waitForURL("/", { timeout: 10000 }); }); test("stress toggle forces REST decision", async ({ page }) => { await page.waitForLoadState("networkidle"); const overridesHeading = page.getByRole("heading", { name: "OVERRIDES" }); const hasOverrides = await overridesHeading .waitFor({ timeout: 10000 }) .then(() => true) .catch(() => false); if (!hasOverrides) { test.skip(); return; } const stressCheckbox = page.getByRole("checkbox", { name: /high stress/i, }); const hasStress = await stressCheckbox.isVisible().catch(() => false); if (!hasStress) { test.skip(); return; } const wasChecked = await stressCheckbox.isChecked(); if (!wasChecked) { await stressCheckbox.click(); await page.waitForTimeout(500); } // Decision should now show REST const decisionCard = page.locator('[data-testid="decision-card"]'); const hasCard = await decisionCard.isVisible().catch(() => false); if (hasCard) { const cardText = await decisionCard.textContent(); expect(cardText).toContain("REST"); } // Clean up if (!wasChecked) { await stressCheckbox.click(); } }); test("PMS toggle forces GENTLE decision", async ({ page }) => { await page.waitForLoadState("networkidle"); const overridesHeading = page.getByRole("heading", { name: "OVERRIDES" }); const hasOverrides = await overridesHeading .waitFor({ timeout: 10000 }) .then(() => true) .catch(() => false); if (!hasOverrides) { test.skip(); return; } const pmsCheckbox = page.getByRole("checkbox", { name: /pms/i }); const hasPms = await pmsCheckbox.isVisible().catch(() => false); if (!hasPms) { test.skip(); return; } const wasChecked = await pmsCheckbox.isChecked(); if (!wasChecked) { await pmsCheckbox.click(); await page.waitForTimeout(500); } // Decision should show GENTLE (unless higher priority override is active) const decisionCard = page.locator('[data-testid="decision-card"]'); const hasCard = await decisionCard.isVisible().catch(() => false); if (hasCard) { const cardText = await decisionCard.textContent(); // PMS forces GENTLE, but flare/stress would override to REST expect(cardText?.includes("GENTLE") || cardText?.includes("REST")).toBe( true, ); } // Clean up if (!wasChecked) { await pmsCheckbox.click(); } }); test("override persists after page refresh", async ({ page }) => { await page.waitForLoadState("networkidle"); const overridesHeading = page.getByRole("heading", { name: "OVERRIDES" }); const hasOverrides = await overridesHeading .waitFor({ timeout: 10000 }) .then(() => true) .catch(() => false); if (!hasOverrides) { test.skip(); return; } const flareCheckbox = page.getByRole("checkbox", { name: /flare mode/i }); const hasFlare = await flareCheckbox.isVisible().catch(() => false); if (!hasFlare) { test.skip(); return; } // Record initial state and toggle if needed const wasInitiallyChecked = await flareCheckbox.isChecked(); // Enable flare override if not already if (!wasInitiallyChecked) { await flareCheckbox.click(); await page.waitForTimeout(500); // Verify it's checked expect(await flareCheckbox.isChecked()).toBe(true); // Refresh the page await page.reload(); await page.waitForLoadState("networkidle"); // Wait for overrides section to reappear await overridesHeading.waitFor({ timeout: 10000 }); // Find the checkbox again after reload const flareCheckboxAfterReload = page.getByRole("checkbox", { name: /flare mode/i, }); const isStillChecked = await flareCheckboxAfterReload.isChecked(); // Override should persist expect(isStillChecked).toBe(true); // Clean up - disable the override await flareCheckboxAfterReload.click(); } }); }); test.describe("mini calendar", () => { test.beforeEach(async ({ page }) => { const email = process.env.TEST_USER_EMAIL; const password = process.env.TEST_USER_PASSWORD; if (!email || !password) { test.skip(); return; } await page.goto("/login"); await page.waitForLoadState("networkidle"); const emailInput = page.getByLabel(/email/i); const hasEmailForm = await emailInput.isVisible().catch(() => false); if (!hasEmailForm) { test.skip(); return; } await emailInput.fill(email); await page.getByLabel(/password/i).fill(password); await page.getByRole("button", { name: /sign in/i }).click(); await page.waitForURL("/", { timeout: 10000 }); }); test("mini calendar displays current month", async ({ page }) => { await page.waitForLoadState("networkidle"); const miniCalendar = page.locator('[data-testid="mini-calendar"]'); const hasCalendar = await miniCalendar.isVisible().catch(() => false); if (hasCalendar) { // Should display month name (January, February, etc.) const monthNames = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; const calendarText = await miniCalendar.textContent(); const hasMonthName = monthNames.some((month) => calendarText?.includes(month), ); expect(hasMonthName).toBe(true); } }); test("mini calendar highlights today", async ({ page }) => { await page.waitForLoadState("networkidle"); const miniCalendar = page.locator('[data-testid="mini-calendar"]'); const hasCalendar = await miniCalendar.isVisible().catch(() => false); if (hasCalendar) { // Today should have distinct styling (aria-current, special class, or ring) const todayCell = miniCalendar.locator('[aria-current="date"]'); const hasTodayMarked = await todayCell.count(); // Or look for cell with today's date that has special styling const today = new Date().getDate().toString(); const todayCellByText = miniCalendar .locator("button, div") .filter({ hasText: new RegExp(`^${today}$`) }); expect(hasTodayMarked > 0 || (await todayCellByText.count()) > 0).toBe( true, ); } }); test("mini calendar shows phase colors", async ({ page }) => { await page.waitForLoadState("networkidle"); const miniCalendar = page.locator('[data-testid="mini-calendar"]'); const hasCalendar = await miniCalendar.isVisible().catch(() => false); if (hasCalendar) { // Calendar cells should have background colors for phases const coloredCells = await miniCalendar .locator("button, [role='gridcell']") .count(); // Should have at least some days rendered expect(coloredCells).toBeGreaterThan(0); } }); test("mini calendar shows navigation controls", async ({ page }) => { await page.waitForLoadState("networkidle"); const miniCalendar = page.locator('[data-testid="mini-calendar"]'); const hasCalendar = await miniCalendar.isVisible().catch(() => false); if (hasCalendar) { // Should have prev/next navigation buttons const prevButton = miniCalendar.getByRole("button", { name: /previous|prev|←|/i, }); const hasPrev = await prevButton.isVisible().catch(() => false); const hasNext = await nextButton.isVisible().catch(() => false); // Should have at least navigation capability expect(hasPrev || hasNext).toBe(true); } }); }); test.describe("onboarding banner", () => { test.beforeEach(async ({ page }) => { const email = process.env.TEST_USER_EMAIL; const password = process.env.TEST_USER_PASSWORD; if (!email || !password) { test.skip(); return; } await page.goto("/login"); await page.waitForLoadState("networkidle"); const emailInput = page.getByLabel(/email/i); const hasEmailForm = await emailInput.isVisible().catch(() => false); if (!hasEmailForm) { test.skip(); return; } await emailInput.fill(email); await page.getByLabel(/password/i).fill(password); await page.getByRole("button", { name: /sign in/i }).click(); await page.waitForURL("/", { timeout: 10000 }); }); test("onboarding prompts appear for incomplete setup", async ({ page }) => { await page.waitForLoadState("networkidle"); // Look for any onboarding/setup prompts const onboardingBanner = page.locator( '[data-testid="onboarding-banner"]', ); const setupPrompts = page.getByText( /connect garmin|set.*period|configure|get started|complete setup/i, ); const hasOnboardingBanner = await onboardingBanner .isVisible() .catch(() => false); const hasSetupPrompts = await setupPrompts .first() .isVisible() .catch(() => false); // Either user has completed setup (no prompts) or prompts are shown // This test verifies the UI handles both states const mainContent = page.locator("main"); await expect(mainContent).toBeVisible(); // If onboarding banner exists, it should have actionable content if (hasOnboardingBanner || hasSetupPrompts) { // Should have links or buttons to complete setup const actionElements = page .getByRole("link") .or(page.getByRole("button")); expect(await actionElements.count()).toBeGreaterThan(0); } }); test("Garmin connection prompt links to settings", async ({ page }) => { await page.waitForLoadState("networkidle"); // Look for Garmin connection prompt const garminPrompt = page.getByText(/connect garmin|garmin.*connect/i); const hasGarminPrompt = await garminPrompt .first() .isVisible() .catch(() => false); if (hasGarminPrompt) { // There should be a link to settings/garmin const garminLink = page.getByRole("link", { name: /connect|garmin|settings/i, }); const hasLink = await garminLink .first() .isVisible() .catch(() => false); if (hasLink) { // Click and verify navigation await garminLink.first().click(); await page.waitForLoadState("networkidle"); // Should navigate to settings or garmin settings page const url = page.url(); expect(url.includes("/settings")).toBe(true); } } }); test("period date prompt allows setting date", async ({ page }) => { await page.waitForLoadState("networkidle"); // Look for period date prompt const periodPrompt = page.getByText( /set.*period|log.*period|first day.*period/i, ); const hasPeriodPrompt = await periodPrompt .first() .isVisible() .catch(() => false); if (hasPeriodPrompt) { // Should have a way to set the period date (button, link, or input) const periodAction = page.getByRole("button", { name: /set|log|add|record/i, }); const hasPeriodAction = await periodAction .first() .isVisible() .catch(() => false); if (hasPeriodAction) { // Clicking should open a date picker or modal await periodAction.first().click(); await page.waitForTimeout(300); // Look for date input or modal const dateInput = page.locator('input[type="date"]'); const modal = page.getByRole("dialog"); const hasDateInput = await dateInput.isVisible().catch(() => false); const hasModal = await modal.isVisible().catch(() => false); expect(hasDateInput || hasModal).toBe(true); // Close modal if opened if (hasModal) { await page.keyboard.press("Escape"); } } } }); }); test.describe("loading states", () => { test.beforeEach(async ({ page }) => { const email = process.env.TEST_USER_EMAIL; const password = process.env.TEST_USER_PASSWORD; if (!email || !password) { test.skip(); return; } await page.goto("/login"); await page.waitForLoadState("networkidle"); const emailInput = page.getByLabel(/email/i); const hasEmailForm = await emailInput.isVisible().catch(() => false); if (!hasEmailForm) { test.skip(); return; } await emailInput.fill(email); await page.getByLabel(/password/i).fill(password); await page.getByRole("button", { name: /sign in/i }).click(); await page.waitForURL("/", { timeout: 10000 }); }); test("skeleton loaders display during data fetch", async ({ page }) => { // Slow down API response to catch loading state await page.route("**/api/today", async (route) => { await new Promise((resolve) => setTimeout(resolve, 1000)); await route.continue(); }); // Refresh to trigger loading state await page.reload(); // Look for skeleton/loading indicators const skeletonClasses = [ ".animate-pulse", '[aria-label*="Loading"]', '[aria-busy="true"]', ".skeleton", ]; let foundSkeleton = false; for (const selector of skeletonClasses) { const skeleton = page.locator(selector); const hasSkeleton = await skeleton .first() .isVisible() .catch(() => false); if (hasSkeleton) { foundSkeleton = true; break; } } // Wait for loading to complete await page.waitForLoadState("networkidle"); // Either found skeleton during load or page loaded too fast // Log the result for debugging purposes if (foundSkeleton) { expect(foundSkeleton).toBe(true); } }); test("dashboard fully loads within reasonable time", async ({ page }) => { const startTime = Date.now(); await page.reload(); await page.waitForLoadState("networkidle"); // Wait for main content to be interactive const mainContent = page.locator("main"); await expect(mainContent).toBeVisible(); const loadTime = Date.now() - startTime; // Dashboard should load within 10 seconds (generous for CI) expect(loadTime).toBeLessThan(10000); }); }); });