Add 36 new E2E tests across 5 test files
All checks were successful
Deploy / deploy (push) Successful in 1m41s
All checks were successful
Deploy / deploy (push) Successful in 1m41s
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>
This commit is contained in:
375
e2e/cycle.spec.ts
Normal file
375
e2e/cycle.spec.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user