// ABOUTME: E2E tests for cycle tracking functionality. // ABOUTME: Tests cycle day display, phase transitions, and period logging. import { expect, test } from "@playwright/test"; test.describe("cycle tracking", () => { test.describe("cycle API", () => { test("cycle/current endpoint requires authentication", async ({ request, }) => { const response = await request.get("/api/cycle/current"); // Should return 401 when not authenticated expect(response.status()).toBe(401); }); }); test.describe("cycle display", () => { 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 shows current cycle day", async ({ page }) => { await page.waitForLoadState("networkidle"); // Wait for loading to complete await page .waitForSelector('[aria-label="Loading cycle info"]', { state: "detached", timeout: 15000, }) .catch(() => {}); // Look for cycle day text (e.g., "Day 12") const dayText = page.getByText(/day \d+/i); const hasDay = await dayText .first() .isVisible() .catch(() => false); // If no cycle data set, should show onboarding const onboarding = page.getByText(/set.*period|log.*period/i); const hasOnboarding = await onboarding .first() .isVisible() .catch(() => false); expect(hasDay || hasOnboarding).toBe(true); }); test("dashboard shows current phase name", async ({ page }) => { await page.waitForLoadState("networkidle"); await page .waitForSelector('[aria-label="Loading cycle info"]', { state: "detached", timeout: 15000, }) .catch(() => {}); // Look for phase name (one of the five phases) const phases = [ "MENSTRUAL", "FOLLICULAR", "OVULATION", "EARLY LUTEAL", "LATE LUTEAL", ]; const phaseTexts = phases.map((p) => page.getByText(new RegExp(p, "i"))); let hasPhase = false; for (const phaseText of phaseTexts) { if ( await phaseText .first() .isVisible() .catch(() => false) ) { hasPhase = true; break; } } // If no cycle data, onboarding should be visible const onboarding = page.getByText(/set.*period|log.*period/i); const hasOnboarding = await onboarding .first() .isVisible() .catch(() => false); expect(hasPhase || hasOnboarding).toBe(true); }); test("plan page shows all 5 phases", async ({ page }) => { await page.goto("/plan"); await page.waitForLoadState("networkidle"); await page .waitForSelector('[aria-label="Loading"]', { state: "detached", timeout: 15000, }) .catch(() => {}); // Phase overview section should show all phases const phaseOverview = page.getByRole("heading", { name: "Phase Overview", }); const hasOverview = await phaseOverview.isVisible().catch(() => false); if (hasOverview) { await expect(page.getByTestId("phase-MENSTRUAL")).toBeVisible(); await expect(page.getByTestId("phase-FOLLICULAR")).toBeVisible(); await expect(page.getByTestId("phase-OVULATION")).toBeVisible(); await expect(page.getByTestId("phase-EARLY_LUTEAL")).toBeVisible(); await expect(page.getByTestId("phase-LATE_LUTEAL")).toBeVisible(); } }); test("phase cards show weekly intensity limits", async ({ page }) => { await page.goto("/plan"); await page.waitForLoadState("networkidle"); await page .waitForSelector('[aria-label="Loading"]', { state: "detached", timeout: 15000, }) .catch(() => {}); const phaseOverview = page.getByRole("heading", { name: "Phase Overview", }); const hasOverview = await phaseOverview.isVisible().catch(() => false); if (hasOverview) { // Each phase card should show min/week limit - use testid for specificity await expect( page.getByTestId("phase-MENSTRUAL").getByText("30 min/week"), ).toBeVisible(); await expect( page.getByTestId("phase-FOLLICULAR").getByText("120 min/week"), ).toBeVisible(); await expect( page.getByTestId("phase-OVULATION").getByText("80 min/week"), ).toBeVisible(); await expect( page.getByTestId("phase-EARLY_LUTEAL").getByText("100 min/week"), ).toBeVisible(); await expect( page.getByTestId("phase-LATE_LUTEAL").getByText("50 min/week"), ).toBeVisible(); } }); }); test.describe("cycle settings", () => { 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("settings page allows cycle length configuration", async ({ page, }) => { await page.goto("/settings"); await page.waitForLoadState("networkidle"); // Look for cycle length input const cycleLengthInput = page.getByLabel(/cycle length/i); const hasCycleLength = await cycleLengthInput .isVisible() .catch(() => false); if (hasCycleLength) { // Should be a number input const inputType = await cycleLengthInput.getAttribute("type"); expect(inputType).toBe("number"); // Should have valid range (21-45 per spec) const min = await cycleLengthInput.getAttribute("min"); const max = await cycleLengthInput.getAttribute("max"); expect(min).toBe("21"); expect(max).toBe("45"); } }); test("settings page shows current cycle length value", async ({ page }) => { await page.goto("/settings"); await page.waitForLoadState("networkidle"); const cycleLengthInput = page.getByLabel(/cycle length/i); const hasCycleLength = await cycleLengthInput .isVisible() .catch(() => false); if (hasCycleLength) { // Should have a value between 21-45 const value = await cycleLengthInput.inputValue(); const numValue = Number.parseInt(value, 10); expect(numValue).toBeGreaterThanOrEqual(21); expect(numValue).toBeLessThanOrEqual(45); } }); }); test.describe("period logging", () => { 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("period history page is accessible", async ({ page }) => { await page.goto("/period-history"); await page.waitForLoadState("networkidle"); // Should show Period History heading const heading = page.getByRole("heading", { name: /period history/i }); await expect(heading).toBeVisible(); }); test("period history shows table or empty state", async ({ page }) => { await page.goto("/period-history"); await page.waitForLoadState("networkidle"); // Should show either period data table or empty state const table = page.locator("table"); const emptyState = page.getByText(/no periods|no history/i); const hasTable = await table.isVisible().catch(() => false); const hasEmpty = await emptyState .first() .isVisible() .catch(() => false); expect(hasTable || hasEmpty).toBe(true); }); test("period history has link back to dashboard", async ({ page }) => { await page.goto("/period-history"); await page.waitForLoadState("networkidle"); const dashboardLink = page.getByRole("link", { name: /dashboard/i }); const hasLink = await dashboardLink.isVisible().catch(() => false); // May have different link text const backLink = page.getByRole("link", { name: /back/i }); const hasBackLink = await backLink.isVisible().catch(() => false); expect(hasLink || hasBackLink).toBe(true); }); }); test.describe("calendar integration", () => { 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("calendar page shows phase colors in legend", async ({ page }) => { await page.goto("/calendar"); await page.waitForLoadState("networkidle"); // Calendar should show phase legend with all phases const legend = page.getByText(/menstrual|follicular|ovulation|luteal/i); const hasLegend = await legend .first() .isVisible() .catch(() => false); if (hasLegend) { // Check for phase emojis in legend per spec const menstrualEmoji = page.getByText(/🩸.*menstrual/i); const follicularEmoji = page.getByText(/🌱.*follicular/i); const hasMenstrual = await menstrualEmoji .isVisible() .catch(() => false); const hasFollicular = await follicularEmoji .isVisible() .catch(() => false); expect(hasMenstrual || hasFollicular).toBe(true); } }); }); });