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>
764 lines
24 KiB
TypeScript
764 lines
24 KiB
TypeScript
// ABOUTME: E2E tests for calendar functionality including ICS feed and calendar view.
|
|
// ABOUTME: Tests calendar display, navigation, and ICS subscription features.
|
|
import { expect, test } from "@playwright/test";
|
|
|
|
test.describe("calendar", () => {
|
|
test.describe("unauthenticated", () => {
|
|
test("calendar page redirects to login when not authenticated", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/calendar");
|
|
|
|
// 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();
|
|
|
|
await page.waitForURL("/", { timeout: 10000 });
|
|
await page.goto("/calendar");
|
|
await page.waitForLoadState("networkidle");
|
|
});
|
|
|
|
test("displays calendar page with heading", async ({ page }) => {
|
|
// Check for the main calendar heading (h1)
|
|
const heading = page.getByRole("heading", {
|
|
name: "Calendar",
|
|
exact: true,
|
|
});
|
|
await expect(heading).toBeVisible();
|
|
});
|
|
|
|
test("shows month view calendar", async ({ page }) => {
|
|
// Look for calendar grid structure
|
|
const calendarGrid = page
|
|
.getByRole("grid")
|
|
.or(page.locator('[data-testid="month-view"]'));
|
|
await expect(calendarGrid).toBeVisible();
|
|
});
|
|
|
|
test("shows month and year display", async ({ page }) => {
|
|
// Calendar should show current month/year
|
|
const monthYear = page.getByText(
|
|
/january|february|march|april|may|june|july|august|september|october|november|december/i,
|
|
);
|
|
await expect(monthYear.first()).toBeVisible();
|
|
});
|
|
|
|
test("has navigation controls for months", async ({ page }) => {
|
|
// Look for previous/next month buttons
|
|
const prevButton = page.getByRole("button", {
|
|
name: /prev|previous|←|back/i,
|
|
});
|
|
const nextButton = page.getByRole("button", { name: /next|→|forward/i });
|
|
|
|
// At least one navigation control should be visible
|
|
const hasPrev = await prevButton.isVisible().catch(() => false);
|
|
const hasNext = await nextButton.isVisible().catch(() => false);
|
|
|
|
expect(hasPrev || hasNext).toBe(true);
|
|
});
|
|
|
|
test("can navigate to previous month", async ({ page }) => {
|
|
const prevButton = page.getByRole("button", { name: /prev|previous|←/i });
|
|
const hasPrev = await prevButton.isVisible().catch(() => false);
|
|
|
|
if (hasPrev) {
|
|
// Click previous month button
|
|
await prevButton.click();
|
|
|
|
// Wait for update - verify page doesn't error
|
|
await page.waitForTimeout(500);
|
|
|
|
// Verify calendar is still rendered
|
|
const monthYear = page.getByText(
|
|
/january|february|march|april|may|june|july|august|september|october|november|december/i,
|
|
);
|
|
await expect(monthYear.first()).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test("can navigate to next month", async ({ page }) => {
|
|
const nextButton = page.getByRole("button", { name: /next|→/i });
|
|
const hasNext = await nextButton.isVisible().catch(() => false);
|
|
|
|
if (hasNext) {
|
|
// Click next
|
|
await nextButton.click();
|
|
|
|
// Wait for update
|
|
await page.waitForTimeout(500);
|
|
}
|
|
});
|
|
|
|
test("shows ICS subscription section", async ({ page }) => {
|
|
// Look for calendar subscription / ICS section
|
|
const subscriptionText = page.getByText(
|
|
/subscribe|subscription|calendar.*url|ics/i,
|
|
);
|
|
const hasSubscription = await subscriptionText
|
|
.first()
|
|
.isVisible()
|
|
.catch(() => false);
|
|
|
|
// This may not be visible if user hasn't generated a token
|
|
if (hasSubscription) {
|
|
await expect(subscriptionText.first()).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test("shows generate or regenerate token button", async ({ page }) => {
|
|
// Look for generate/regenerate button
|
|
const tokenButton = page.getByRole("button", {
|
|
name: /generate|regenerate/i,
|
|
});
|
|
const hasButton = await tokenButton.isVisible().catch(() => false);
|
|
|
|
if (hasButton) {
|
|
await expect(tokenButton).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test("shows copy button when URL exists", async ({ page }) => {
|
|
// Copy button only shows when URL is generated
|
|
const copyButton = page.getByRole("button", { name: /copy/i });
|
|
const hasCopy = await copyButton.isVisible().catch(() => false);
|
|
|
|
if (hasCopy) {
|
|
await expect(copyButton).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test("shows back navigation", async ({ page }) => {
|
|
const backLink = page.getByRole("link", { name: /back|home|dashboard/i });
|
|
await expect(backLink).toBeVisible();
|
|
});
|
|
|
|
test("can navigate back to dashboard", async ({ page }) => {
|
|
const backLink = page.getByRole("link", { name: /back|home|dashboard/i });
|
|
await backLink.click();
|
|
|
|
await expect(page).toHaveURL("/");
|
|
});
|
|
});
|
|
|
|
test.describe("ICS endpoint", () => {
|
|
test("ICS endpoint returns error for invalid user", async ({ page }) => {
|
|
const response = await page.request.get(
|
|
"/api/calendar/invalid-user-id/invalid-token.ics",
|
|
);
|
|
|
|
// Should return 404 (user not found) or 500 (PocketBase not connected in test env)
|
|
expect([404, 500]).toContain(response.status());
|
|
});
|
|
|
|
test("ICS endpoint returns error for invalid token", async ({ page }) => {
|
|
// Need a valid user ID but invalid token - this would require setup
|
|
// For now, just verify the endpoint exists and returns appropriate error
|
|
const response = await page.request.get("/api/calendar/test/invalid.ics");
|
|
|
|
// Should return 404 (user not found), 401 (invalid token), or 500 (PocketBase not connected)
|
|
expect([401, 404, 500]).toContain(response.status());
|
|
});
|
|
});
|
|
|
|
test.describe("calendar regenerate token API", () => {
|
|
test("regenerate token requires authentication", async ({ page }) => {
|
|
const response = await page.request.post(
|
|
"/api/calendar/regenerate-token",
|
|
);
|
|
|
|
// Should return 401 Unauthorized
|
|
expect(response.status()).toBe(401);
|
|
});
|
|
});
|
|
|
|
test.describe("calendar display features", () => {
|
|
// 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("/calendar");
|
|
await page.waitForLoadState("networkidle");
|
|
});
|
|
|
|
test("today is highlighted in calendar view", async ({ page }) => {
|
|
// Today's date should be highlighted with distinct styling
|
|
const today = new Date();
|
|
const dayNumber = today.getDate().toString();
|
|
|
|
// Look for today button/cell with special styling
|
|
const todayCell = page
|
|
.locator('[data-today="true"]')
|
|
.or(page.locator('[aria-current="date"]'))
|
|
.or(page.getByRole("button", { name: new RegExp(`${dayNumber}`) }));
|
|
|
|
const hasTodayHighlight = await todayCell
|
|
.first()
|
|
.isVisible()
|
|
.catch(() => false);
|
|
|
|
if (hasTodayHighlight) {
|
|
await expect(todayCell.first()).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test("phase colors are visible in calendar days", async ({ page }) => {
|
|
// Calendar days should have phase coloring (background color classes)
|
|
const dayButtons = page.getByRole("button").filter({
|
|
has: page.locator('[class*="bg-"]'),
|
|
});
|
|
|
|
const hasColoredDays = await dayButtons
|
|
.first()
|
|
.isVisible()
|
|
.catch(() => false);
|
|
|
|
// If there's cycle data, some days should have color
|
|
if (hasColoredDays) {
|
|
await expect(dayButtons.first()).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test("calendar shows phase legend", async ({ page }) => {
|
|
// Look for phase legend with phase names
|
|
const legendText = page.getByText(
|
|
/menstrual|follicular|ovulation|luteal/i,
|
|
);
|
|
const hasLegend = await legendText
|
|
.first()
|
|
.isVisible()
|
|
.catch(() => false);
|
|
|
|
if (hasLegend) {
|
|
await expect(legendText.first()).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test("calendar has Today button for quick navigation", async ({ page }) => {
|
|
const todayButton = page.getByRole("button", { name: /today/i });
|
|
const hasTodayButton = await todayButton.isVisible().catch(() => false);
|
|
|
|
if (hasTodayButton) {
|
|
await expect(todayButton).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test("can navigate multiple months and return to today", async ({
|
|
page,
|
|
}) => {
|
|
// Navigate forward a few months
|
|
const nextButton = page.getByRole("button", { name: /next|→/i });
|
|
const hasNext = await nextButton.isVisible().catch(() => false);
|
|
|
|
if (hasNext) {
|
|
await nextButton.click();
|
|
await page.waitForTimeout(300);
|
|
await nextButton.click();
|
|
await page.waitForTimeout(300);
|
|
|
|
// Look for Today button to return
|
|
const todayButton = page.getByRole("button", { name: /today/i });
|
|
const hasTodayButton = await todayButton.isVisible().catch(() => false);
|
|
|
|
if (hasTodayButton) {
|
|
await todayButton.click();
|
|
await page.waitForTimeout(300);
|
|
|
|
// Should be back to current month
|
|
const currentMonth = new Date().toLocaleString("default", {
|
|
month: "long",
|
|
});
|
|
const monthText = page.getByText(new RegExp(currentMonth, "i"));
|
|
const isCurrentMonth = await monthText
|
|
.first()
|
|
.isVisible()
|
|
.catch(() => false);
|
|
expect(isCurrentMonth).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe("ICS feed content", () => {
|
|
// 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("/calendar");
|
|
await page.waitForLoadState("networkidle");
|
|
});
|
|
|
|
test("can generate calendar URL", async ({ page }) => {
|
|
// Look for generate button
|
|
const generateButton = page.getByRole("button", {
|
|
name: /generate|regenerate/i,
|
|
});
|
|
const hasGenerate = await generateButton.isVisible().catch(() => false);
|
|
|
|
if (hasGenerate) {
|
|
await generateButton.click();
|
|
await page.waitForTimeout(1000);
|
|
|
|
// After generating, URL should be displayed
|
|
const urlDisplay = page.getByText(/\.ics|calendar.*url/i);
|
|
const hasUrl = await urlDisplay
|
|
.first()
|
|
.isVisible()
|
|
.catch(() => false);
|
|
|
|
if (hasUrl) {
|
|
await expect(urlDisplay.first()).toBeVisible();
|
|
}
|
|
}
|
|
});
|
|
|
|
test("calendar URL contains user ID and token", async ({ page }) => {
|
|
// If URL is displayed, verify it has expected format
|
|
const urlInput = page.locator('input[readonly][value*=".ics"]');
|
|
const hasUrlInput = await urlInput.isVisible().catch(() => false);
|
|
|
|
if (hasUrlInput) {
|
|
const url = await urlInput.inputValue();
|
|
// URL should contain /api/calendar/ and end with .ics
|
|
expect(url).toContain("/api/calendar/");
|
|
expect(url).toContain(".ics");
|
|
}
|
|
});
|
|
|
|
test("copy button copies URL to clipboard", async ({ page, context }) => {
|
|
// Grant clipboard permissions
|
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
|
|
|
const copyButton = page.getByRole("button", { name: /copy/i });
|
|
const hasCopy = await copyButton.isVisible().catch(() => false);
|
|
|
|
if (hasCopy) {
|
|
await copyButton.click();
|
|
|
|
// Verify clipboard has content (clipboard read may not work in all env)
|
|
const clipboardContent = await page
|
|
.evaluate(() => navigator.clipboard.readText())
|
|
.catch(() => null);
|
|
|
|
if (clipboardContent) {
|
|
expect(clipboardContent).toContain(".ics");
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
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");
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe("accessibility", () => {
|
|
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");
|
|
});
|
|
|
|
test("calendar grid has proper ARIA role and label", async ({ page }) => {
|
|
// Calendar should have role="grid" per WAI-ARIA calendar pattern
|
|
const calendarGrid = page.getByRole("grid", { name: /calendar/i });
|
|
await expect(calendarGrid).toBeVisible();
|
|
});
|
|
|
|
test("day cells have descriptive aria-labels", async ({ page }) => {
|
|
// Day buttons should have descriptive aria-labels including date and phase info
|
|
const dayButtons = page.locator("button[data-day]");
|
|
const hasDayButtons = await dayButtons
|
|
.first()
|
|
.isVisible()
|
|
.catch(() => false);
|
|
|
|
if (!hasDayButtons) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
// Get the first visible day button's aria-label
|
|
const firstDayButton = dayButtons.first();
|
|
const ariaLabel = await firstDayButton.getAttribute("aria-label");
|
|
|
|
// Aria-label should contain date information (month and year)
|
|
expect(ariaLabel).toMatch(
|
|
/january|february|march|april|may|june|july|august|september|october|november|december/i,
|
|
);
|
|
expect(ariaLabel).toMatch(/\d{4}/); // Should contain year
|
|
});
|
|
|
|
test("keyboard navigation with arrow keys works", async ({ page }) => {
|
|
// Focus on a day button in the calendar grid
|
|
const dayButtons = page.locator("button[data-day]");
|
|
const hasDayButtons = await dayButtons
|
|
.first()
|
|
.isVisible()
|
|
.catch(() => false);
|
|
|
|
if (!hasDayButtons) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
// Click a day button to focus it
|
|
const calendarGrid = page.getByRole("grid", { name: /calendar/i });
|
|
const hasGrid = await calendarGrid.isVisible().catch(() => false);
|
|
|
|
if (!hasGrid) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
// Focus the grid and press Tab to focus first day
|
|
await calendarGrid.focus();
|
|
await page.keyboard.press("Tab");
|
|
|
|
// Get currently focused element
|
|
const focusedBefore = await page.evaluate(() => {
|
|
const el = document.activeElement;
|
|
return el ? el.getAttribute("data-day") : null;
|
|
});
|
|
|
|
// Press ArrowRight to move to next day
|
|
await page.keyboard.press("ArrowRight");
|
|
|
|
// Get new focused element
|
|
const focusedAfter = await page.evaluate(() => {
|
|
const el = document.activeElement;
|
|
return el ? el.getAttribute("data-day") : null;
|
|
});
|
|
|
|
// If both values exist, verify navigation occurred
|
|
if (focusedBefore && focusedAfter) {
|
|
expect(focusedAfter).not.toBe(focusedBefore);
|
|
}
|
|
});
|
|
|
|
test("navigation buttons have accessible labels", async ({ page }) => {
|
|
// Previous and next month buttons should have aria-labels
|
|
const prevButton = page.getByRole("button", { name: /previous month/i });
|
|
const nextButton = page.getByRole("button", { name: /next month/i });
|
|
|
|
const hasPrev = await prevButton.isVisible().catch(() => false);
|
|
const hasNext = await nextButton.isVisible().catch(() => false);
|
|
|
|
// At least one navigation button should be accessible
|
|
expect(hasPrev || hasNext).toBe(true);
|
|
|
|
if (hasPrev) {
|
|
await expect(prevButton).toBeVisible();
|
|
}
|
|
if (hasNext) {
|
|
await expect(nextButton).toBeVisible();
|
|
}
|
|
});
|
|
});
|
|
});
|