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>
380 lines
12 KiB
TypeScript
380 lines
12 KiB
TypeScript
// 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);
|
|
}
|
|
});
|
|
});
|
|
});
|