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>
376 lines
11 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|
|
});
|