Add 14 new E2E tests for ICS content validation and settings form
All checks were successful
Deploy / deploy (push) Successful in 2m27s
All checks were successful
Deploy / deploy (push) Successful in 2m27s
Calendar ICS content validation tests (7): - VCALENDAR structure validation - VEVENT entries verification - Phase events with emojis (🩸🌱🌸🌙🌑) - CATEGORIES for calendar color coding - 90-day span coverage - Warning events inclusion - Content-type header validation Settings form validation tests (7): - Notification time HH:MM format acceptance - Cycle length minimum (21) boundary validation - Cycle length maximum (45) boundary validation - Timezone field editability - Current cycle length value display - Settings persistence after page reload - Save button loading state Total E2E test count: 165 tests across 12 files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -417,4 +417,220 @@ test.describe("calendar", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("ICS feed content validation", () => {
|
||||
// These tests fetch and validate actual ICS content
|
||||
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("/calendar");
|
||||
await page.waitForLoadState("networkidle");
|
||||
});
|
||||
|
||||
async function getIcsContent(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<string | null> {
|
||||
// Find the ICS URL from the page
|
||||
const urlInput = page.locator('input[readonly][value*=".ics"]');
|
||||
const hasUrlInput = await urlInput.isVisible().catch(() => false);
|
||||
|
||||
if (!hasUrlInput) {
|
||||
// Try generating a token first
|
||||
const generateButton = page.getByRole("button", {
|
||||
name: /generate|regenerate/i,
|
||||
});
|
||||
const hasGenerate = await generateButton.isVisible().catch(() => false);
|
||||
|
||||
if (hasGenerate) {
|
||||
await generateButton.click();
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
}
|
||||
|
||||
const urlInputAfter = page.locator('input[readonly][value*=".ics"]');
|
||||
const hasUrlAfter = await urlInputAfter.isVisible().catch(() => false);
|
||||
|
||||
if (!hasUrlAfter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = await urlInputAfter.inputValue();
|
||||
|
||||
// Fetch the ICS content
|
||||
const response = await page.request.get(url);
|
||||
if (response.ok()) {
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
test("ICS feed contains valid VCALENDAR structure", async ({ page }) => {
|
||||
const icsContent = await getIcsContent(page);
|
||||
|
||||
if (!icsContent) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify basic ICS structure
|
||||
expect(icsContent).toContain("BEGIN:VCALENDAR");
|
||||
expect(icsContent).toContain("END:VCALENDAR");
|
||||
expect(icsContent).toContain("VERSION:2.0");
|
||||
expect(icsContent).toContain("PRODID:");
|
||||
});
|
||||
|
||||
test("ICS feed contains VEVENT entries", async ({ page }) => {
|
||||
const icsContent = await getIcsContent(page);
|
||||
|
||||
if (!icsContent) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Should have at least some events
|
||||
expect(icsContent).toContain("BEGIN:VEVENT");
|
||||
expect(icsContent).toContain("END:VEVENT");
|
||||
});
|
||||
|
||||
test("ICS feed contains phase events with emojis", async ({ page }) => {
|
||||
const icsContent = await getIcsContent(page);
|
||||
|
||||
if (!icsContent) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Per calendar.md spec, events should have emojis:
|
||||
// 🩸 Menstrual, 🌱 Follicular, 🌸 Ovulation, 🌙 Early Luteal, 🌑 Late Luteal
|
||||
const phaseEmojis = ["🩸", "🌱", "🌸", "🌙", "🌑"];
|
||||
const hasEmojis = phaseEmojis.some((emoji) => icsContent.includes(emoji));
|
||||
expect(hasEmojis).toBe(true);
|
||||
});
|
||||
|
||||
test("ICS feed has CATEGORIES for calendar color coding", async ({
|
||||
page,
|
||||
}) => {
|
||||
const icsContent = await getIcsContent(page);
|
||||
|
||||
if (!icsContent) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Per calendar.md spec, phases have color categories:
|
||||
// Red, Green, Pink, Yellow, Orange
|
||||
const colorCategories = ["Red", "Green", "Pink", "Yellow", "Orange"];
|
||||
const hasCategories = colorCategories.some((color) =>
|
||||
icsContent.includes(`CATEGORIES:${color}`),
|
||||
);
|
||||
|
||||
// If user has cycle data, categories should be present
|
||||
if (
|
||||
icsContent.includes("MENSTRUAL") ||
|
||||
icsContent.includes("FOLLICULAR")
|
||||
) {
|
||||
expect(hasCategories).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("ICS feed spans approximately 90 days", async ({ page }) => {
|
||||
const icsContent = await getIcsContent(page);
|
||||
|
||||
if (!icsContent) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Count DTSTART entries to estimate event span
|
||||
const dtStartMatches = icsContent.match(/DTSTART/g);
|
||||
|
||||
// Should have multiple events (phases + warnings)
|
||||
// 3 months of phases (~15 phase events) + warning events
|
||||
if (dtStartMatches) {
|
||||
expect(dtStartMatches.length).toBeGreaterThan(5);
|
||||
}
|
||||
});
|
||||
|
||||
test("ICS feed includes warning events", async ({ page }) => {
|
||||
const icsContent = await getIcsContent(page);
|
||||
|
||||
if (!icsContent) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Per ics.ts, warning events include these phrases
|
||||
const warningIndicators = [
|
||||
"Late Luteal Phase",
|
||||
"CRITICAL PHASE",
|
||||
"⚠️",
|
||||
"🔴",
|
||||
];
|
||||
const hasWarnings = warningIndicators.some((indicator) =>
|
||||
icsContent.includes(indicator),
|
||||
);
|
||||
|
||||
// Warnings should be present if feed has events
|
||||
if (icsContent.includes("BEGIN:VEVENT")) {
|
||||
expect(hasWarnings).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("ICS content type is text/calendar", async ({ page }) => {
|
||||
// Find the ICS URL from the page
|
||||
const urlInput = page.locator('input[readonly][value*=".ics"]');
|
||||
const hasUrlInput = await urlInput.isVisible().catch(() => false);
|
||||
|
||||
if (!hasUrlInput) {
|
||||
const generateButton = page.getByRole("button", {
|
||||
name: /generate|regenerate/i,
|
||||
});
|
||||
const hasGenerate = await generateButton.isVisible().catch(() => false);
|
||||
|
||||
if (hasGenerate) {
|
||||
await generateButton.click();
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
}
|
||||
|
||||
const urlInputAfter = page.locator('input[readonly][value*=".ics"]');
|
||||
const hasUrlAfter = await urlInputAfter.isVisible().catch(() => false);
|
||||
|
||||
if (!hasUrlAfter) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = await urlInputAfter.inputValue();
|
||||
const response = await page.request.get(url);
|
||||
|
||||
if (response.ok()) {
|
||||
const contentType = response.headers()["content-type"];
|
||||
expect(contentType).toContain("text/calendar");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -190,4 +190,229 @@ test.describe("settings", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user