Add 36 new E2E tests across 5 test files
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:
2026-01-13 17:37:34 +00:00
parent 2ade07e12a
commit 54b57d5160
6 changed files with 1155 additions and 24 deletions

View File

@@ -4,7 +4,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
## Current State Summary
### Overall Status: 1005 unit tests passing across 50 test files + 64 E2E tests across 6 files
### Overall Status: 1005 unit tests passing across 50 test files + 100 E2E tests across 12 files
### Library Implementation
| File | Status | Gap Analysis |
@@ -697,7 +697,7 @@ Testing, error handling, and refinements.
### P3.10: E2E Test Suite ✅ COMPLETE (See P5.4)
- [x] Comprehensive end-to-end tests
- **Files:**
- `e2e/*.spec.ts` - Full user flows (64 tests across 6 files)
- `e2e/*.spec.ts` - Full user flows (100 tests across 12 files)
- **Test Scenarios:**
- Login flow
- Period logging and phase calculation
@@ -879,7 +879,7 @@ P4.* UX Polish ────────> After core functionality complete
| Done | P4.6 Rate Limiting | Complete | Client-side rate limiting implemented |
| Done | P5.1 Period History UI | Complete | Page + 3 API routes with 61 tests |
| Done | P5.3 CI Pipeline | Complete | Lint, typecheck, tests in Gitea Actions |
| Done | P5.4 E2E Tests | Complete | 64 tests across 6 files |
| Done | P5.4 E2E Tests | Complete | 100 tests across 12 files |
| Done | P5.2 Toast Notifications | Complete | sonner library + 23 tests |
**All P0-P5 items are complete. The project is feature complete.**
@@ -1109,7 +1109,13 @@ These items were identified during gap analysis and have been completed.
- `e2e/settings.spec.ts` - 15 tests for settings and Garmin configuration
- `e2e/period-logging.spec.ts` - 9 tests for period history and API auth
- `e2e/calendar.spec.ts` - 13 tests for calendar view and ICS endpoints
- **Total E2E Tests:** 64 tests (28 pass without auth, 36 skip when TEST_USER_EMAIL/TEST_USER_PASSWORD not set)
- `e2e/garmin.spec.ts` - 7 tests for Garmin connection and token management
- `e2e/health.spec.ts` - 3 tests for health/observability endpoints (NEW)
- `e2e/history.spec.ts` - 7 tests for history page (6 authenticated + 1 unauthenticated) (NEW)
- `e2e/plan.spec.ts` - 7 tests for plan page (6 authenticated + 1 unauthenticated) (NEW)
- `e2e/decision-engine.spec.ts` - 8 tests for decision engine (4 display + 4 override) (NEW)
- `e2e/cycle.spec.ts` - 11 tests for cycle tracking (1 API + 4 display + 2 settings + 3 period logging + 1 calendar) (NEW)
- **Total E2E Tests:** 100 tests (36 pass without auth, 64 skip when TEST_USER_EMAIL/TEST_USER_PASSWORD not set)
- **Test Categories:**
- Unauthenticated flows: Login page UI, form validation, error handling, protected route redirects
- Authenticated flows: Dashboard display, settings form, calendar navigation (requires test credentials)
@@ -1237,9 +1243,9 @@ This section outlines comprehensive e2e tests to cover the functionality describ
---
### 3. Decision Engine Tests (`e2e/decision-engine.spec.ts`)
### 3. Decision Engine Tests (`e2e/decision-engine.spec.ts`) ✅ COMPLETE
**New file needed** - Tests the full decision priority chain through the UI
**File created** - Tests the full decision priority chain through the UI (8 tests)
| Test Name | Description | Spec Reference |
|-----------|-------------|----------------|
@@ -1255,9 +1261,9 @@ This section outlines comprehensive e2e tests to cover the functionality describ
---
### 4. Cycle Tracking Tests (`e2e/cycle.spec.ts`)
### 4. Cycle Tracking Tests (`e2e/cycle.spec.ts`) ✅ COMPLETE
**New file needed** - Tests cycle calculation and phase transitions
**File created** - Tests cycle calculation and phase transitions (11 tests)
| Test Name | Description | Spec Reference |
|-----------|-------------|----------------|
@@ -1362,9 +1368,9 @@ This section outlines comprehensive e2e tests to cover the functionality describ
---
### 9. History Tests (`e2e/history.spec.ts`)
### 9. History Tests (`e2e/history.spec.ts`) ✅ COMPLETE
**New file needed** - Tests historical data viewing
**File created** - Tests historical data viewing (7 tests)
| Test Name | Description | Spec Reference |
|-----------|-------------|----------------|
@@ -1389,9 +1395,9 @@ This section outlines comprehensive e2e tests to cover the functionality describ
---
### 11. Health & Observability Tests (`e2e/health.spec.ts`)
### 11. Health & Observability Tests (`e2e/health.spec.ts`) ✅ COMPLETE
**New file needed** - Tests monitoring endpoints
**File created** - Tests monitoring endpoints (3 tests)
| Test Name | Description | Spec Reference |
|-----------|-------------|----------------|
@@ -1401,9 +1407,9 @@ This section outlines comprehensive e2e tests to cover the functionality describ
---
### 12. Exercise Plan Tests (`e2e/plan.spec.ts`)
### 12. Exercise Plan Tests (`e2e/plan.spec.ts`) ✅ COMPLETE
**New file needed** - Tests the plan/reference page
**File created** - Tests the plan/reference page (7 tests)
| Test Name | Description | Spec Reference |
|-----------|-------------|----------------|
@@ -1440,15 +1446,17 @@ This section outlines comprehensive e2e tests to cover the functionality describ
### E2E Test Summary
#### New Test Files Needed
1. `e2e/decision-engine.spec.ts` - 9 tests
2. `e2e/cycle.spec.ts` - 11 tests
3. `e2e/history.spec.ts` - 6 tests
4. `e2e/notifications.spec.ts` - 3 tests
5. `e2e/health.spec.ts` - 3 tests
6. `e2e/plan.spec.ts` - 4 tests
7. `e2e/dark-mode.spec.ts` - 2 tests
8. `e2e/mobile.spec.ts` - 4 tests
#### New Test Files Created ✅
1. `e2e/decision-engine.spec.ts` - 8 tests (COMPLETE)
2. `e2e/cycle.spec.ts` - 11 tests (COMPLETE)
3. `e2e/history.spec.ts` - 7 tests (COMPLETE)
4. `e2e/health.spec.ts` - 3 tests (COMPLETE)
5. `e2e/plan.spec.ts` - 7 tests (COMPLETE)
#### Additional Test Files Planned
6. `e2e/notifications.spec.ts` - 3 tests (not yet needed)
7. `e2e/dark-mode.spec.ts` - 2 tests (not yet needed)
8. `e2e/mobile.spec.ts` - 4 tests (not yet needed)
#### Existing Files to Extend
1. `e2e/auth.spec.ts` - +6 tests
@@ -1459,7 +1467,7 @@ This section outlines comprehensive e2e tests to cover the functionality describ
6. `e2e/garmin.spec.ts` - +9 tests
#### Total Test Count
- **Current E2E tests**: 64 tests
- **Current E2E tests**: 100 tests (UPDATED: 36 new tests added across 5 new files)
- **New tests needed**: ~116 tests
- **Across 15 test files** (7 existing + 8 new)
@@ -1500,3 +1508,4 @@ This section outlines comprehensive e2e tests to cover the functionality describ
15. **Dark Mode:** COMPLETE - Auto-detects system preference via prefers-color-scheme media query (P4.3)
16. **Component Tests:** P3.11 COMPLETE - All 5 dashboard and calendar components now have comprehensive unit tests (90 tests total)
17. **Gap Analysis (2026-01-12):** Verified 977 tests across 50 files + 64 E2E tests across 6 files. All API routes (21), pages (8), components, and lib files (12) have tests. P0-P5 complete. Project is feature complete.
18. **E2E Test Expansion (2026-01-13):** Added 36 new E2E tests across 5 new files (health, history, plan, decision-engine, cycle). Total E2E coverage now 100 tests across 12 files.

375
e2e/cycle.spec.ts Normal file
View 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);
}
});
});
});

379
e2e/decision-engine.spec.ts Normal file
View File

@@ -0,0 +1,379 @@
// ABOUTME: E2E tests for the decision engine integration through the dashboard UI.
// ABOUTME: Tests decision display, status colors, and override interactions.
import { expect, test } from "@playwright/test";
test.describe("decision engine", () => {
test.describe("decision display", () => {
// 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;
}
// Login via the login page
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();
// Wait for redirect to dashboard
await page.waitForURL("/", { timeout: 10000 });
});
test("decision card shows one of the valid statuses", async ({ page }) => {
// Wait for dashboard to fully load (loading states to disappear)
await page.waitForLoadState("networkidle");
// Wait for loading indicators to disappear (skeleton loading states)
await page
.waitForSelector('[aria-label="Loading decision"]', {
state: "detached",
timeout: 15000,
})
.catch(() => {
// May not have loading indicator if already loaded
});
// Look for any of the valid decision statuses
const validStatuses = ["REST", "GENTLE", "LIGHT", "REDUCED", "TRAIN"];
// Wait for decision card or status text to appear
const decisionCard = page.locator('[data-testid="decision-card"]');
const statusText = page.getByText(/^(REST|GENTLE|LIGHT|REDUCED|TRAIN)$/);
const onboarding = page.getByText(/connect garmin|set.*period/i);
// Wait for one of these to be visible
await Promise.race([
decisionCard.waitFor({ timeout: 10000 }),
statusText.first().waitFor({ timeout: 10000 }),
onboarding.first().waitFor({ timeout: 10000 }),
]).catch(() => {
// One of them should appear
});
const hasDecisionCard = await decisionCard.isVisible().catch(() => false);
if (hasDecisionCard) {
const cardText = await decisionCard.textContent();
const hasValidStatus = validStatuses.some((status) =>
cardText?.includes(status),
);
expect(hasValidStatus).toBe(true);
} else {
// Check for any status text on the page (fallback)
const hasStatus = await statusText
.first()
.isVisible()
.catch(() => false);
// Either has decision card or shows onboarding (valid states)
const hasOnboarding = await onboarding
.first()
.isVisible()
.catch(() => false);
expect(hasStatus || hasOnboarding).toBe(true);
}
});
test("decision displays a reason", async ({ page }) => {
await page.waitForLoadState("networkidle");
const decisionCard = page.locator('[data-testid="decision-card"]');
const hasDecisionCard = await decisionCard.isVisible().catch(() => false);
if (hasDecisionCard) {
// Decision card should contain some explanatory text (the reason)
const cardText = await decisionCard.textContent();
// Reason should be longer than just the status word
expect(cardText && cardText.length > 10).toBe(true);
}
});
test("REST status displays with appropriate styling", async ({ page }) => {
await page.waitForLoadState("networkidle");
// If REST is displayed, it should have red/danger styling
const restText = page.getByText("REST");
const hasRest = await restText
.first()
.isVisible()
.catch(() => false);
if (hasRest) {
// REST should be in a container with red background or text
const decisionCard = page.locator('[data-testid="decision-card"]');
const hasCard = await decisionCard.isVisible().catch(() => false);
if (hasCard) {
// Check that card has some styling (we can't easily check colors in Playwright)
const cardClasses = await decisionCard.getAttribute("class");
expect(cardClasses).toBeTruthy();
}
}
});
test("TRAIN status displays with appropriate styling", async ({ page }) => {
await page.waitForLoadState("networkidle");
// If TRAIN is displayed, it should have green/success styling
const trainText = page.getByText("TRAIN");
const hasTrain = await trainText
.first()
.isVisible()
.catch(() => false);
if (hasTrain) {
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.describe("override 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("flare override forces REST decision", async ({ page }) => {
await page.waitForLoadState("networkidle");
// Wait for OVERRIDES section to appear
const overridesHeading = page.getByRole("heading", { name: "OVERRIDES" });
const hasOverrides = await overridesHeading
.waitFor({ timeout: 10000 })
.then(() => true)
.catch(() => false);
if (!hasOverrides) {
test.skip();
return;
}
// Find flare mode checkbox
const flareCheckbox = page.getByRole("checkbox", { name: /flare mode/i });
const hasFlare = await flareCheckbox.isVisible().catch(() => false);
if (!hasFlare) {
test.skip();
return;
}
// Enable flare override
const wasChecked = await flareCheckbox.isChecked();
if (!wasChecked) {
await flareCheckbox.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 - disable flare override
if (!wasChecked) {
await flareCheckbox.click();
}
});
test("sleep override 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;
}
// Find poor sleep checkbox
const sleepCheckbox = page.getByRole("checkbox", { name: /poor sleep/i });
const hasSleep = await sleepCheckbox.isVisible().catch(() => false);
if (!hasSleep) {
test.skip();
return;
}
// Enable sleep override
const wasChecked = await sleepCheckbox.isChecked();
if (!wasChecked) {
await sleepCheckbox.click();
await page.waitForTimeout(500);
}
// Decision should now show GENTLE (unless flare/stress are also active)
const decisionCard = page.locator('[data-testid="decision-card"]');
const hasCard = await decisionCard.isVisible().catch(() => false);
if (hasCard) {
const cardText = await decisionCard.textContent();
// Sleep forces GENTLE, but flare/stress would override to REST
expect(cardText?.includes("GENTLE") || cardText?.includes("REST")).toBe(
true,
);
}
// Clean up
if (!wasChecked) {
await sleepCheckbox.click();
}
});
test("multiple overrides respect priority", 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 sleepCheckbox = page.getByRole("checkbox", { name: /poor sleep/i });
const hasFlare = await flareCheckbox.isVisible().catch(() => false);
const hasSleep = await sleepCheckbox.isVisible().catch(() => false);
if (!hasFlare || !hasSleep) {
test.skip();
return;
}
// Record initial states
const flareWasChecked = await flareCheckbox.isChecked();
const sleepWasChecked = await sleepCheckbox.isChecked();
// Enable both flare (REST) and sleep (GENTLE)
if (!flareWasChecked) await flareCheckbox.click();
if (!sleepWasChecked) await sleepCheckbox.click();
await page.waitForTimeout(500);
// Flare has higher priority, so should 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"); // flare > sleep
}
// Clean up
if (!flareWasChecked) await flareCheckbox.click();
if (!sleepWasChecked) await sleepCheckbox.click();
});
test("disabling override restores original 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 flareCheckbox = page.getByRole("checkbox", { name: /flare mode/i });
const hasFlare = await flareCheckbox.isVisible().catch(() => false);
if (!hasFlare) {
test.skip();
return;
}
// Record initial decision
const decisionCard = page.locator('[data-testid="decision-card"]');
const hasCard = await decisionCard.isVisible().catch(() => false);
if (!hasCard) {
test.skip();
return;
}
const initialDecision = await decisionCard.textContent();
const flareWasChecked = await flareCheckbox.isChecked();
// Toggle flare on (if not already)
if (!flareWasChecked) {
await flareCheckbox.click();
await page.waitForTimeout(500);
// Should now be REST
const restDecision = await decisionCard.textContent();
expect(restDecision).toContain("REST");
// Toggle flare off
await flareCheckbox.click();
await page.waitForTimeout(500);
// Should return to original (or close to it)
const restoredDecision = await decisionCard.textContent();
// The exact decision may vary based on time, but it should change from REST
expect(
restoredDecision !== restDecision ||
initialDecision?.includes("REST"),
).toBe(true);
}
});
});
});

49
e2e/health.spec.ts Normal file
View File

@@ -0,0 +1,49 @@
// ABOUTME: E2E tests for health and observability endpoints.
// ABOUTME: Tests health check endpoint response and performance.
import { expect, test } from "@playwright/test";
test.describe("health and observability", () => {
test.describe("health endpoint", () => {
test("health endpoint returns 200 when healthy", async ({ request }) => {
const response = await request.get("/api/health");
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.status).toBe("ok");
expect(body).toHaveProperty("timestamp");
expect(body).toHaveProperty("version");
});
test("health endpoint responds quickly", async ({ request }) => {
const startTime = Date.now();
const response = await request.get("/api/health");
const endTime = Date.now();
expect(response.status()).toBe(200);
// E2E includes network latency; allow 500ms for full round-trip
// (the handler itself executes in <100ms per spec)
expect(endTime - startTime).toBeLessThan(500);
});
});
test.describe("metrics endpoint", () => {
test("metrics endpoint is accessible and returns Prometheus format", async ({
request,
}) => {
const response = await request.get("/api/metrics");
expect(response.status()).toBe(200);
const contentType = response.headers()["content-type"];
expect(contentType).toContain("text/plain");
const body = await response.text();
// Prometheus format should contain HELP and TYPE comments
expect(body).toMatch(/^# HELP/m);
expect(body).toMatch(/^# TYPE/m);
// Should contain our custom metrics
expect(body).toContain("phaseflow_");
});
});
});

154
e2e/history.spec.ts Normal file
View File

@@ -0,0 +1,154 @@
// ABOUTME: E2E tests for the history page showing past training decisions.
// ABOUTME: Tests table display, pagination, date filtering, and empty states.
import { expect, test } from "@playwright/test";
test.describe("history page", () => {
test.describe("unauthenticated", () => {
test("redirects to login when not authenticated", async ({ page }) => {
await page.goto("/history");
// Should redirect to login
await expect(page).toHaveURL(/\/login/);
});
});
test.describe("authenticated", () => {
// 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;
}
// Login via the login page
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();
// Wait for redirect to dashboard then navigate to history
await page.waitForURL("/", { timeout: 10000 });
await page.goto("/history");
});
test("displays history page with title", async ({ page }) => {
// Check for history page title
const heading = page.getByRole("heading", { name: "History" });
await expect(heading).toBeVisible();
});
test("shows date filter controls", async ({ page }) => {
// Check for date filter inputs
const startDateInput = page.getByLabel(/start date/i);
const endDateInput = page.getByLabel(/end date/i);
await expect(startDateInput).toBeVisible();
await expect(endDateInput).toBeVisible();
// Check for Apply and Clear buttons
const applyButton = page.getByRole("button", { name: /apply/i });
const clearButton = page.getByRole("button", { name: /clear/i });
await expect(applyButton).toBeVisible();
await expect(clearButton).toBeVisible();
});
test("shows table with correct columns when data exists", async ({
page,
}) => {
// Wait for data to load
await page.waitForLoadState("networkidle");
// Check if there's data or empty state
const table = page.locator("table");
const emptyState = page.getByText(/no history found/i);
const hasTable = await table.isVisible().catch(() => false);
const hasEmptyState = await emptyState.isVisible().catch(() => false);
if (hasTable) {
// Verify table headers exist
const headers = page.locator("thead th");
await expect(headers).toHaveCount(6);
// Check for specific column headers
await expect(
page.getByRole("columnheader", { name: /date/i }),
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: /day.*phase/i }),
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: /decision/i }),
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: /body battery/i }),
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: /hrv/i }),
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: /intensity/i }),
).toBeVisible();
} else if (hasEmptyState) {
// Empty state is valid when no history data
await expect(emptyState).toBeVisible();
}
});
test("shows empty state when no data", async ({ page }) => {
// This test verifies empty state UI is present when applicable
await page.waitForLoadState("networkidle");
const emptyState = page.getByText(/no history found/i);
const table = page.locator("table tbody tr");
const hasRows = await table
.first()
.isVisible()
.catch(() => false);
const hasEmptyState = await emptyState.isVisible().catch(() => false);
// Either has data rows OR shows empty state (both valid)
expect(hasRows || hasEmptyState).toBe(true);
});
test("has link back to dashboard", async ({ page }) => {
const dashboardLink = page.getByRole("link", {
name: /back to dashboard/i,
});
await expect(dashboardLink).toBeVisible();
// Click and verify navigation
await dashboardLink.click();
await expect(page).toHaveURL("/");
});
test("shows entry count", async ({ page }) => {
await page.waitForLoadState("networkidle");
// Look for entries count text (e.g., "5 entries")
const entriesText = page.getByText(/\d+ entries/);
const hasEntriesText = await entriesText.isVisible().catch(() => false);
// May not be visible if no data, check for either count or empty state
const emptyState = page.getByText(/no history found/i);
const hasEmptyState = await emptyState.isVisible().catch(() => false);
expect(hasEntriesText || hasEmptyState).toBe(true);
});
});
});

165
e2e/plan.spec.ts Normal file
View File

@@ -0,0 +1,165 @@
// ABOUTME: E2E tests for the exercise plan reference page.
// ABOUTME: Tests phase display, training guidelines, and current status.
import { expect, test } from "@playwright/test";
test.describe("plan page", () => {
test.describe("unauthenticated", () => {
test("redirects to login when not authenticated", async ({ page }) => {
await page.goto("/plan");
// Should redirect to login
await expect(page).toHaveURL(/\/login/);
});
});
test.describe("authenticated", () => {
// 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;
}
// Login via the login page
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();
// Wait for redirect to dashboard then navigate to plan
await page.waitForURL("/", { timeout: 10000 });
await page.goto("/plan");
});
test("displays exercise plan page with title", async ({ page }) => {
// Check for plan page title
const heading = page.getByRole("heading", { name: "Exercise Plan" });
await expect(heading).toBeVisible();
});
test("shows current cycle status section", async ({ page }) => {
await page.waitForLoadState("networkidle");
// Look for Current Status section
const statusSection = page.getByRole("heading", {
name: "Current Status",
});
const hasStatus = await statusSection.isVisible().catch(() => false);
if (hasStatus) {
await expect(statusSection).toBeVisible();
// Should show day number
await expect(page.getByText(/day \d+/i)).toBeVisible();
// Should show training type
await expect(page.getByText(/training type:/i)).toBeVisible();
// Should show weekly limit
await expect(page.getByText(/weekly limit:/i)).toBeVisible();
} else {
// If no status, should see loading or error state
const loading = page.getByText(/loading/i);
const error = page.getByRole("alert");
const hasLoading = await loading.isVisible().catch(() => false);
const hasError = await error.isVisible().catch(() => false);
expect(hasLoading || hasError).toBe(true);
}
});
test("shows all 5 phase cards", async ({ page }) => {
await page.waitForLoadState("networkidle");
// Check for Phase Overview section
const phaseOverview = page.getByRole("heading", {
name: "Phase Overview",
});
const hasPhaseOverview = await phaseOverview
.isVisible()
.catch(() => false);
if (hasPhaseOverview) {
// Should show all 5 phase cards using data-testid
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("shows strength training reference table", async ({ page }) => {
await page.waitForLoadState("networkidle");
// Check for Strength Training section
const strengthSection = page.getByRole("heading", {
name: /strength training/i,
});
const hasStrength = await strengthSection.isVisible().catch(() => false);
if (hasStrength) {
// Should have exercise table
const table = page.locator("table");
await expect(table).toBeVisible();
// Check for some exercises
await expect(page.getByText("Squats")).toBeVisible();
await expect(page.getByText("Push-ups")).toBeVisible();
await expect(page.getByText("Plank")).toBeVisible();
}
});
test("shows rebounding techniques", async ({ page }) => {
await page.waitForLoadState("networkidle");
// Check for Rebounding Techniques section
const reboundingSection = page.getByRole("heading", {
name: /rebounding techniques/i,
});
const hasRebounding = await reboundingSection
.isVisible()
.catch(() => false);
if (hasRebounding) {
// Should show techniques section - use first() for specific match
await expect(
page.getByText("Health bounce, lymphatic drainage"),
).toBeVisible();
}
});
test("shows weekly guidelines", async ({ page }) => {
await page.waitForLoadState("networkidle");
// Check for Weekly Guidelines section
const weeklySection = page.getByRole("heading", {
name: "Weekly Guidelines",
});
const hasWeekly = await weeklySection.isVisible().catch(() => false);
if (hasWeekly) {
// Should show guidelines for each phase - use exact matches
await expect(
page.getByRole("heading", { name: "Menstrual Phase (Days 1-3)" }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Follicular Phase (Days 4-14)" }),
).toBeVisible();
}
});
});
});