All checks were successful
Deploy / deploy (push) Successful in 1m40s
- calendar.spec.ts: +4 accessibility tests (ARIA role, aria-labels, keyboard navigation, accessible nav buttons) - settings.spec.ts: +1 error recovery test (retry after failed save) - mobile.spec.ts: +3 calendar mobile tests (rendering, touch targets, navigation) Total E2E tests: 190 → 198 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
760 lines
24 KiB
TypeScript
760 lines
24 KiB
TypeScript
// ABOUTME: E2E tests for settings page including preferences and logout.
|
|
// ABOUTME: Tests form rendering, validation, submission, and logout functionality.
|
|
import { expect, test } from "@playwright/test";
|
|
|
|
test.describe("settings", () => {
|
|
test.describe("unauthenticated", () => {
|
|
test("redirects to login when not authenticated", async ({ page }) => {
|
|
await page.goto("/settings");
|
|
|
|
// Should redirect to /login
|
|
await expect(page).toHaveURL(/\/login/);
|
|
});
|
|
|
|
test("garmin settings redirects to login when not authenticated", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/settings/garmin");
|
|
|
|
// 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 settings
|
|
await page.waitForURL("/", { timeout: 10000 });
|
|
await page.goto("/settings");
|
|
await page.waitForLoadState("networkidle");
|
|
});
|
|
|
|
test("displays settings form with required fields", async ({ page }) => {
|
|
// Check for cycle length input
|
|
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
|
await expect(cycleLengthInput).toBeVisible();
|
|
|
|
// Check for notification time input
|
|
const notificationTimeInput = page.getByLabel(/notification time/i);
|
|
await expect(notificationTimeInput).toBeVisible();
|
|
|
|
// Check for timezone input
|
|
const timezoneInput = page.getByLabel(/timezone/i);
|
|
await expect(timezoneInput).toBeVisible();
|
|
});
|
|
|
|
test("shows save button", async ({ page }) => {
|
|
const saveButton = page.getByRole("button", { name: /save/i });
|
|
await expect(saveButton).toBeVisible();
|
|
});
|
|
|
|
test("shows logout button", async ({ page }) => {
|
|
const logoutButton = page.getByRole("button", { name: /log ?out/i });
|
|
await expect(logoutButton).toBeVisible();
|
|
});
|
|
|
|
test("shows link to garmin settings", async ({ page }) => {
|
|
const garminLink = page.getByRole("link", { name: /manage|garmin/i });
|
|
await expect(garminLink).toBeVisible();
|
|
});
|
|
|
|
test("shows back to dashboard link", async ({ page }) => {
|
|
const backLink = page.getByRole("link", { name: /back|dashboard/i });
|
|
await expect(backLink).toBeVisible();
|
|
});
|
|
|
|
test("can update cycle length", async ({ page }) => {
|
|
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
|
|
|
// Clear and enter new value
|
|
await cycleLengthInput.fill("30");
|
|
|
|
// Click save
|
|
const saveButton = page.getByRole("button", { name: /save/i });
|
|
await saveButton.click();
|
|
|
|
// Should show success message or no error
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Either success message or value persisted
|
|
const errorMessage = page.locator('[role="alert"]').filter({
|
|
hasText: /error|failed/i,
|
|
});
|
|
const hasError = await errorMessage.isVisible().catch(() => false);
|
|
|
|
// No error means success
|
|
expect(hasError).toBe(false);
|
|
});
|
|
|
|
test("validates cycle length range", async ({ page }) => {
|
|
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
|
|
|
// Enter invalid value (too low)
|
|
await cycleLengthInput.fill("10");
|
|
|
|
// Click save
|
|
const saveButton = page.getByRole("button", { name: /save/i });
|
|
await saveButton.click();
|
|
|
|
// Should show validation error or HTML5 validation
|
|
await page.waitForTimeout(500);
|
|
});
|
|
|
|
test("can navigate to garmin settings", async ({ page }) => {
|
|
const garminLink = page.getByRole("link", { name: /manage|garmin/i });
|
|
await garminLink.click();
|
|
|
|
await expect(page).toHaveURL(/\/settings\/garmin/);
|
|
});
|
|
|
|
test("can navigate back to dashboard", async ({ page }) => {
|
|
const backLink = page.getByRole("link", { name: /back|dashboard/i });
|
|
await backLink.click();
|
|
|
|
await expect(page).toHaveURL("/");
|
|
});
|
|
|
|
test("logout redirects to login", async ({ page }) => {
|
|
const logoutButton = page.getByRole("button", { name: /log ?out/i });
|
|
await logoutButton.click();
|
|
|
|
// Should redirect to login page
|
|
await expect(page).toHaveURL(/\/login/, { timeout: 10000 });
|
|
});
|
|
});
|
|
|
|
test.describe("garmin 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;
|
|
}
|
|
|
|
// Login and navigate to garmin settings
|
|
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 });
|
|
await page.goto("/settings/garmin");
|
|
await page.waitForLoadState("networkidle");
|
|
});
|
|
|
|
test("displays garmin connection status", async ({ page }) => {
|
|
// Look for connection status indicator
|
|
const statusText = page.getByText(/connected|not connected|status/i);
|
|
await expect(statusText.first()).toBeVisible();
|
|
});
|
|
|
|
test("shows back navigation", async ({ page }) => {
|
|
const backLink = page.getByRole("link", { name: /back|settings/i });
|
|
await expect(backLink).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe("settings form validation", () => {
|
|
// 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;
|
|
}
|
|
|
|
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 });
|
|
await page.goto("/settings");
|
|
await page.waitForLoadState("networkidle");
|
|
});
|
|
|
|
test("notification time field accepts valid HH:MM format", async ({
|
|
page,
|
|
}) => {
|
|
const notificationTimeInput = page.getByLabel(/notification time/i);
|
|
const isVisible = await notificationTimeInput
|
|
.isVisible()
|
|
.catch(() => false);
|
|
|
|
if (!isVisible) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
// Enter a valid time
|
|
await notificationTimeInput.fill("07:00");
|
|
|
|
const saveButton = page.getByRole("button", { name: /save/i });
|
|
await saveButton.click();
|
|
|
|
// Wait for save
|
|
await page.waitForTimeout(1000);
|
|
|
|
// No error should be shown
|
|
const errorMessage = page.locator('[role="alert"]').filter({
|
|
hasText: /error|failed|invalid/i,
|
|
});
|
|
const hasError = await errorMessage.isVisible().catch(() => false);
|
|
expect(hasError).toBe(false);
|
|
});
|
|
|
|
test("cycle length rejects value below minimum (21)", async ({ page }) => {
|
|
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
|
const isVisible = await cycleLengthInput.isVisible().catch(() => false);
|
|
|
|
if (!isVisible) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
// Enter invalid value (too low)
|
|
await cycleLengthInput.fill("20");
|
|
|
|
const saveButton = page.getByRole("button", { name: /save/i });
|
|
await saveButton.click();
|
|
|
|
// Wait for validation
|
|
await page.waitForTimeout(500);
|
|
|
|
// Either HTML5 validation or error message should appear
|
|
// Input should have min attribute or form shows error
|
|
const inputMin = await cycleLengthInput.getAttribute("min");
|
|
if (inputMin) {
|
|
expect(Number.parseInt(inputMin, 10)).toBeGreaterThanOrEqual(21);
|
|
}
|
|
});
|
|
|
|
test("cycle length rejects value above maximum (45)", async ({ page }) => {
|
|
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
|
const isVisible = await cycleLengthInput.isVisible().catch(() => false);
|
|
|
|
if (!isVisible) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
// Enter invalid value (too high)
|
|
await cycleLengthInput.fill("50");
|
|
|
|
const saveButton = page.getByRole("button", { name: /save/i });
|
|
await saveButton.click();
|
|
|
|
// Wait for validation
|
|
await page.waitForTimeout(500);
|
|
|
|
// Input should have max attribute
|
|
const inputMax = await cycleLengthInput.getAttribute("max");
|
|
if (inputMax) {
|
|
expect(Number.parseInt(inputMax, 10)).toBeLessThanOrEqual(45);
|
|
}
|
|
});
|
|
|
|
test("timezone field is editable", async ({ page }) => {
|
|
const timezoneInput = page.getByLabel(/timezone/i);
|
|
const isVisible = await timezoneInput.isVisible().catch(() => false);
|
|
|
|
if (!isVisible) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
// Timezone could be select or input
|
|
const inputType = await timezoneInput.evaluate((el) =>
|
|
el.tagName.toLowerCase(),
|
|
);
|
|
|
|
if (inputType === "select") {
|
|
// Should have options
|
|
const options = timezoneInput.locator("option");
|
|
const optionCount = await options.count();
|
|
expect(optionCount).toBeGreaterThan(0);
|
|
} else {
|
|
// Should be able to type in it
|
|
const isEditable = await timezoneInput.isEditable();
|
|
expect(isEditable).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("current cycle length value is displayed", async ({ page }) => {
|
|
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
|
const isVisible = await cycleLengthInput.isVisible().catch(() => false);
|
|
|
|
if (!isVisible) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
// Should have a current value
|
|
const value = await cycleLengthInput.inputValue();
|
|
|
|
// Value should be a number in valid range
|
|
const numValue = Number.parseInt(value, 10);
|
|
if (!Number.isNaN(numValue)) {
|
|
expect(numValue).toBeGreaterThanOrEqual(21);
|
|
expect(numValue).toBeLessThanOrEqual(45);
|
|
}
|
|
});
|
|
|
|
test("settings changes persist after page reload", async ({ page }) => {
|
|
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
|
const isVisible = await cycleLengthInput.isVisible().catch(() => false);
|
|
|
|
if (!isVisible) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
// Get current value
|
|
const originalValue = await cycleLengthInput.inputValue();
|
|
|
|
// Set a different valid value
|
|
const newValue = originalValue === "28" ? "30" : "28";
|
|
await cycleLengthInput.fill(newValue);
|
|
|
|
// Save
|
|
const saveButton = page.getByRole("button", { name: /save/i });
|
|
await saveButton.click();
|
|
await page.waitForTimeout(1500);
|
|
|
|
// Reload the page
|
|
await page.reload();
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// Check the value persisted
|
|
const cycleLengthAfter = page.getByLabel(/cycle length/i);
|
|
const afterValue = await cycleLengthAfter.inputValue();
|
|
|
|
// Either it persisted or was rejected - check it's a valid number
|
|
const numValue = Number.parseInt(afterValue, 10);
|
|
expect(numValue).toBeGreaterThanOrEqual(21);
|
|
expect(numValue).toBeLessThanOrEqual(45);
|
|
|
|
// Restore original value
|
|
await cycleLengthAfter.fill(originalValue);
|
|
await saveButton.click();
|
|
await page.waitForTimeout(500);
|
|
});
|
|
|
|
test("save button shows loading state during submission", async ({
|
|
page,
|
|
}) => {
|
|
const saveButton = page.getByRole("button", { name: /save/i });
|
|
const isVisible = await saveButton.isVisible().catch(() => false);
|
|
|
|
if (!isVisible) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
// Initial state should not be disabled
|
|
const isDisabledBefore = await saveButton.isDisabled();
|
|
expect(isDisabledBefore).toBe(false);
|
|
|
|
// Click save and quickly check for loading state
|
|
await saveButton.click();
|
|
|
|
// Wait for submission to complete
|
|
await page.waitForTimeout(1000);
|
|
|
|
// After completion, button should be enabled again
|
|
const isDisabledAfter = await saveButton.isDisabled();
|
|
expect(isDisabledAfter).toBe(false);
|
|
});
|
|
|
|
test("notification time changes persist after page reload", async ({
|
|
page,
|
|
}) => {
|
|
const notificationTimeInput = page.getByLabel(/notification time/i);
|
|
const isVisible = await notificationTimeInput
|
|
.isVisible()
|
|
.catch(() => false);
|
|
|
|
if (!isVisible) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
// Get current value
|
|
const originalValue = await notificationTimeInput.inputValue();
|
|
|
|
// Set a different valid time (toggle between 08:00 and 09:00)
|
|
const newValue = originalValue === "08:00" ? "09:00" : "08:00";
|
|
await notificationTimeInput.fill(newValue);
|
|
|
|
// Save
|
|
const saveButton = page.getByRole("button", { name: /save/i });
|
|
await saveButton.click();
|
|
await page.waitForTimeout(1500);
|
|
|
|
// Reload the page
|
|
await page.reload();
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// Check the value persisted
|
|
const notificationTimeAfter = page.getByLabel(/notification time/i);
|
|
const afterValue = await notificationTimeAfter.inputValue();
|
|
|
|
expect(afterValue).toBe(newValue);
|
|
|
|
// Restore original value
|
|
await notificationTimeAfter.fill(originalValue);
|
|
await saveButton.click();
|
|
await page.waitForTimeout(500);
|
|
});
|
|
|
|
test("timezone changes persist after page reload", async ({ page }) => {
|
|
const timezoneInput = page.getByLabel(/timezone/i);
|
|
const isVisible = await timezoneInput.isVisible().catch(() => false);
|
|
|
|
if (!isVisible) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
// Get current value
|
|
const originalValue = await timezoneInput.inputValue();
|
|
|
|
// Set a different timezone (toggle between two common timezones)
|
|
const newValue =
|
|
originalValue === "America/New_York"
|
|
? "America/Los_Angeles"
|
|
: "America/New_York";
|
|
await timezoneInput.fill(newValue);
|
|
|
|
// Save
|
|
const saveButton = page.getByRole("button", { name: /save/i });
|
|
await saveButton.click();
|
|
await page.waitForTimeout(1500);
|
|
|
|
// Reload the page
|
|
await page.reload();
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// Check the value persisted
|
|
const timezoneAfter = page.getByLabel(/timezone/i);
|
|
const afterValue = await timezoneAfter.inputValue();
|
|
|
|
expect(afterValue).toBe(newValue);
|
|
|
|
// Restore original value
|
|
await timezoneAfter.fill(originalValue);
|
|
await saveButton.click();
|
|
await page.waitForTimeout(500);
|
|
});
|
|
|
|
test("multiple settings changes persist after page reload", async ({
|
|
page,
|
|
}) => {
|
|
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
|
const notificationTimeInput = page.getByLabel(/notification time/i);
|
|
const timezoneInput = page.getByLabel(/timezone/i);
|
|
|
|
const cycleLengthVisible = await cycleLengthInput
|
|
.isVisible()
|
|
.catch(() => false);
|
|
const notificationTimeVisible = await notificationTimeInput
|
|
.isVisible()
|
|
.catch(() => false);
|
|
const timezoneVisible = await timezoneInput
|
|
.isVisible()
|
|
.catch(() => false);
|
|
|
|
if (!cycleLengthVisible || !notificationTimeVisible || !timezoneVisible) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
// Get all original values
|
|
const originalCycleLength = await cycleLengthInput.inputValue();
|
|
const originalNotificationTime = await notificationTimeInput.inputValue();
|
|
const originalTimezone = await timezoneInput.inputValue();
|
|
|
|
// Set different values for all fields
|
|
const newCycleLength = originalCycleLength === "28" ? "30" : "28";
|
|
const newNotificationTime =
|
|
originalNotificationTime === "08:00" ? "09:00" : "08:00";
|
|
const newTimezone =
|
|
originalTimezone === "America/New_York"
|
|
? "America/Los_Angeles"
|
|
: "America/New_York";
|
|
|
|
await cycleLengthInput.fill(newCycleLength);
|
|
await notificationTimeInput.fill(newNotificationTime);
|
|
await timezoneInput.fill(newTimezone);
|
|
|
|
// Save all changes at once
|
|
const saveButton = page.getByRole("button", { name: /save/i });
|
|
await saveButton.click();
|
|
await page.waitForTimeout(1500);
|
|
|
|
// Reload the page
|
|
await page.reload();
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// Verify all values persisted
|
|
const cycleLengthAfter = page.getByLabel(/cycle length/i);
|
|
const notificationTimeAfter = page.getByLabel(/notification time/i);
|
|
const timezoneAfter = page.getByLabel(/timezone/i);
|
|
|
|
expect(await cycleLengthAfter.inputValue()).toBe(newCycleLength);
|
|
expect(await notificationTimeAfter.inputValue()).toBe(
|
|
newNotificationTime,
|
|
);
|
|
expect(await timezoneAfter.inputValue()).toBe(newTimezone);
|
|
|
|
// Restore all original values
|
|
await cycleLengthAfter.fill(originalCycleLength);
|
|
await notificationTimeAfter.fill(originalNotificationTime);
|
|
await timezoneAfter.fill(originalTimezone);
|
|
await saveButton.click();
|
|
await page.waitForTimeout(500);
|
|
});
|
|
|
|
test("cycle length persistence verifies exact saved value", async ({
|
|
page,
|
|
}) => {
|
|
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
|
const isVisible = await cycleLengthInput.isVisible().catch(() => false);
|
|
|
|
if (!isVisible) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
// Get current value
|
|
const originalValue = await cycleLengthInput.inputValue();
|
|
|
|
// Set a specific different valid value
|
|
const newValue = originalValue === "28" ? "31" : "28";
|
|
await cycleLengthInput.fill(newValue);
|
|
|
|
// Save
|
|
const saveButton = page.getByRole("button", { name: /save/i });
|
|
await saveButton.click();
|
|
await page.waitForTimeout(1500);
|
|
|
|
// Reload the page
|
|
await page.reload();
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// Check the exact value persisted (not just range validation)
|
|
const cycleLengthAfter = page.getByLabel(/cycle length/i);
|
|
const afterValue = await cycleLengthAfter.inputValue();
|
|
|
|
expect(afterValue).toBe(newValue);
|
|
|
|
// Restore original value
|
|
await cycleLengthAfter.fill(originalValue);
|
|
await saveButton.click();
|
|
await page.waitForTimeout(500);
|
|
});
|
|
|
|
test("settings form shows correct values after save without reload", async ({
|
|
page,
|
|
}) => {
|
|
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
|
const notificationTimeInput = page.getByLabel(/notification time/i);
|
|
|
|
const cycleLengthVisible = await cycleLengthInput
|
|
.isVisible()
|
|
.catch(() => false);
|
|
const notificationTimeVisible = await notificationTimeInput
|
|
.isVisible()
|
|
.catch(() => false);
|
|
|
|
if (!cycleLengthVisible || !notificationTimeVisible) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
// Get original values
|
|
const originalCycleLength = await cycleLengthInput.inputValue();
|
|
const originalNotificationTime = await notificationTimeInput.inputValue();
|
|
|
|
// Change values
|
|
const newCycleLength = originalCycleLength === "28" ? "29" : "28";
|
|
const newNotificationTime =
|
|
originalNotificationTime === "08:00" ? "10:00" : "08:00";
|
|
|
|
await cycleLengthInput.fill(newCycleLength);
|
|
await notificationTimeInput.fill(newNotificationTime);
|
|
|
|
// Save
|
|
const saveButton = page.getByRole("button", { name: /save/i });
|
|
await saveButton.click();
|
|
await page.waitForTimeout(1500);
|
|
|
|
// Verify values are still showing the new values without reload
|
|
expect(await cycleLengthInput.inputValue()).toBe(newCycleLength);
|
|
expect(await notificationTimeInput.inputValue()).toBe(
|
|
newNotificationTime,
|
|
);
|
|
|
|
// Restore original values
|
|
await cycleLengthInput.fill(originalCycleLength);
|
|
await notificationTimeInput.fill(originalNotificationTime);
|
|
await saveButton.click();
|
|
await page.waitForTimeout(500);
|
|
});
|
|
});
|
|
|
|
test.describe("error recovery", () => {
|
|
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 });
|
|
await page.goto("/settings");
|
|
await page.waitForLoadState("networkidle");
|
|
});
|
|
|
|
test("shows error message and allows retry when save fails", async ({
|
|
page,
|
|
}) => {
|
|
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
|
const isVisible = await cycleLengthInput.isVisible().catch(() => false);
|
|
|
|
if (!isVisible) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
// Get original value for restoration
|
|
const originalValue = await cycleLengthInput.inputValue();
|
|
|
|
// Intercept the save request and make it fail once, then succeed
|
|
let failureCount = 0;
|
|
await page.route("**/api/user", async (route) => {
|
|
if (route.request().method() === "PATCH") {
|
|
if (failureCount === 0) {
|
|
failureCount++;
|
|
// First request fails with server error
|
|
await route.fulfill({
|
|
status: 500,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ error: "Server error" }),
|
|
});
|
|
} else {
|
|
// Subsequent requests succeed - let them through
|
|
await route.continue();
|
|
}
|
|
} else {
|
|
await route.continue();
|
|
}
|
|
});
|
|
|
|
// Change the cycle length
|
|
const newValue = originalValue === "28" ? "32" : "28";
|
|
await cycleLengthInput.fill(newValue);
|
|
|
|
// Click save - should fail
|
|
const saveButton = page.getByRole("button", { name: /save/i });
|
|
await saveButton.click();
|
|
|
|
// Wait for error handling to complete
|
|
await page.waitForTimeout(1000);
|
|
|
|
// The key test is that the form remains usable after a failed save
|
|
// Error handling may show a toast or just keep the form editable
|
|
|
|
// Verify form is still editable (not stuck in loading state)
|
|
const isEditable = await cycleLengthInput.isEditable();
|
|
expect(isEditable).toBe(true);
|
|
|
|
// Verify save button is enabled for retry
|
|
const isButtonEnabled = !(await saveButton.isDisabled());
|
|
expect(isButtonEnabled).toBe(true);
|
|
|
|
// Try saving again - should succeed this time
|
|
await saveButton.click();
|
|
await page.waitForTimeout(1500);
|
|
|
|
// Form should still be functional
|
|
const isEditableAfterRetry = await cycleLengthInput.isEditable();
|
|
expect(isEditableAfterRetry).toBe(true);
|
|
|
|
// Clean up route interception
|
|
await page.unroute("**/api/user");
|
|
|
|
// Restore original value
|
|
await cycleLengthInput.fill(originalValue);
|
|
await saveButton.click();
|
|
await page.waitForTimeout(500);
|
|
});
|
|
});
|
|
});
|