Files
phaseflow/e2e/cycle.spec.ts
Petru Paler 54b57d5160
All checks were successful
Deploy / deploy (push) Successful in 1m41s
Add 36 new E2E tests across 5 test files
New E2E test files:
- e2e/health.spec.ts: 3 tests for health/observability endpoints
- e2e/history.spec.ts: 7 tests for history page
- e2e/plan.spec.ts: 7 tests for exercise plan page
- e2e/decision-engine.spec.ts: 8 tests for decision display and overrides
- e2e/cycle.spec.ts: 11 tests for cycle tracking, settings, and period logging

Total E2E tests: 100 (up from 64)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 17:37:34 +00:00

376 lines
11 KiB
TypeScript

// 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);
}
});
});
});