// 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) { // Wait for both API calls when clicking the checkbox await Promise.all([ page.waitForResponse("**/api/overrides"), page.waitForResponse("**/api/today"), flareCheckbox.click(), ]); // Should now be REST (flare mode forces rest) const restDecision = await decisionCard.textContent(); expect(restDecision).toContain("REST"); // Toggle flare off and wait for API calls await Promise.all([ page.waitForResponse("**/api/overrides"), page.waitForResponse("**/api/today"), flareCheckbox.click(), ]); // 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); } }); }); });