- Add Playwright E2E testing framework with cross-browser support (Chrome, Firefox) - Create authentication helpers for CalDAV server integration - Implement calendar interaction helpers with event creation, drag-and-drop, and view switching - Add comprehensive drag-and-drop test suite with event cleanup - Configure CI/CD integration with Gitea Actions for headless testing - Support both local development and CI environments with proper dependency management - Include video recording, screenshots, and HTML reporting for test debugging - Handle Firefox-specific timing and interaction challenges with force clicks and timeouts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
278 lines
11 KiB
TypeScript
278 lines
11 KiB
TypeScript
import { Page, expect, Locator, TestInfo } from '@playwright/test';
|
||
|
||
export class CalendarHelpers {
|
||
constructor(private page: Page) {}
|
||
|
||
async waitForCalendarLoad() {
|
||
// Wait for calendar to be visible (either month view grid or week view container)
|
||
await expect(
|
||
this.page.locator('.calendar-grid').or(this.page.locator('.week-view-container'))
|
||
).toBeVisible({ timeout: 5000 });
|
||
|
||
// Wait for any loading spinners to disappear (optional, may not exist)
|
||
try {
|
||
await this.page.waitForSelector('.calendar-loading', { state: 'hidden', timeout: 2000 });
|
||
} catch {
|
||
// Loading spinner might not exist, that's ok
|
||
}
|
||
}
|
||
|
||
async switchToWeekView() {
|
||
await this.page.selectOption('.view-selector-dropdown', 'week');
|
||
await this.waitForCalendarLoad();
|
||
}
|
||
|
||
async switchToMonthView() {
|
||
await this.page.selectOption('.view-selector-dropdown', 'month');
|
||
await this.waitForCalendarLoad();
|
||
}
|
||
|
||
async getCurrentView(): Promise<'week' | 'month'> {
|
||
const selectedValue = await this.page.locator('.view-selector-dropdown').inputValue();
|
||
return selectedValue as 'week' | 'month';
|
||
}
|
||
|
||
async navigateToNextPeriod() {
|
||
await this.page.getByRole('button', { name: '›' }).click();
|
||
await this.waitForCalendarLoad();
|
||
}
|
||
|
||
async navigateToPreviousPeriod() {
|
||
await this.page.getByRole('button', { name: '‹' }).click();
|
||
await this.waitForCalendarLoad();
|
||
}
|
||
|
||
async navigateToToday() {
|
||
await this.page.getByRole('button', { name: 'Today' }).click();
|
||
await this.waitForCalendarLoad();
|
||
}
|
||
|
||
async openCreateEventModal(timeSlot?: string) {
|
||
// Check if we're in week view or month view to determine creation method
|
||
const isWeekView = await this.page.locator('.week-view-container').count() > 0;
|
||
|
||
if (isWeekView) {
|
||
// In week view, try element-based drag first (simpler and more reliable)
|
||
const timeSlots = this.page.locator('.time-slot');
|
||
if (await timeSlots.count() > 1) {
|
||
const startSlot = timeSlots.first();
|
||
const endSlot = timeSlots.nth(2); // Skip one to create a longer event
|
||
|
||
// Force the drag
|
||
await startSlot.dragTo(endSlot, { force: true });
|
||
|
||
// Wait for the create event modal to appear
|
||
await expect(this.page.locator('.create-event-modal, .modal')).toBeVisible({ timeout: 5000 });
|
||
} else {
|
||
throw new Error('Not enough time slots available for drag-to-create event');
|
||
}
|
||
} else {
|
||
// In month view, try right-clicking on calendar day
|
||
const calendarDay = this.page.locator('.calendar-day').first();
|
||
if (await calendarDay.count() > 0) {
|
||
await calendarDay.click({ button: 'right' });
|
||
|
||
// Wait for context menu to appear
|
||
await expect(this.page.locator('.context-menu, .calendar-context-menu')).toBeVisible({ timeout: 3000 });
|
||
|
||
// Click "Create Event" in the context menu
|
||
const createEventOption = this.page.getByText(/create event/i);
|
||
await expect(createEventOption).toBeVisible();
|
||
await createEventOption.click();
|
||
|
||
// Wait for the create event modal to appear
|
||
await expect(this.page.locator('.create-event-modal, .modal')).toBeVisible({ timeout: 5000 });
|
||
} else {
|
||
throw new Error('Could not find calendar day for event creation in month view');
|
||
}
|
||
}
|
||
}
|
||
|
||
async createEvent(eventData: {
|
||
title: string;
|
||
startTime?: string;
|
||
endTime?: string;
|
||
description?: string;
|
||
calendar?: string;
|
||
}, testInfo?: TestInfo) {
|
||
// Open modal by clicking on calendar (using default 14:00 time slot if available)
|
||
await this.openCreateEventModal(eventData.startTime || '14:00');
|
||
|
||
// Fill in event details - use exact label match
|
||
const titleInput = this.page.getByLabel('Event Title *').or(this.page.locator('#event-title'));
|
||
await titleInput.fill(eventData.title);
|
||
const titleScreenshot = await this.page.screenshot({ fullPage: true });
|
||
if (testInfo) await testInfo.attach('debug-after-title-fill.png', { body: titleScreenshot, contentType: 'image/png' });
|
||
|
||
if (eventData.startTime) {
|
||
const startTimeInput = this.page.locator('#start-time');
|
||
await startTimeInput.fill(eventData.startTime);
|
||
const startTimeScreenshot = await this.page.screenshot({ fullPage: true });
|
||
if (testInfo) await testInfo.attach('debug-after-start-time-fill.png', { body: startTimeScreenshot, contentType: 'image/png' });
|
||
}
|
||
|
||
if (eventData.endTime) {
|
||
const endTimeInput = this.page.locator('#end-time');
|
||
await endTimeInput.fill(eventData.endTime);
|
||
const endTimeScreenshot = await this.page.screenshot({ fullPage: true });
|
||
if (testInfo) await testInfo.attach('debug-after-end-time-fill.png', { body: endTimeScreenshot, contentType: 'image/png' });
|
||
}
|
||
|
||
if (eventData.description) {
|
||
const descInput = this.page.getByLabel('Description').or(this.page.getByLabel('Notes'));
|
||
await descInput.fill(eventData.description);
|
||
}
|
||
|
||
if (eventData.calendar) {
|
||
await this.page.selectOption('[name="calendar"], .calendar-select', eventData.calendar);
|
||
}
|
||
|
||
// Submit the form
|
||
const submitButton = this.page.getByRole('button', { name: 'Create Event' })
|
||
.or(this.page.getByRole('button', { name: 'Save' }))
|
||
.or(this.page.getByRole('button', { name: 'Create' }));
|
||
|
||
// Take screenshot before clicking submit button
|
||
const beforeSubmitScreenshot = await this.page.screenshot({ fullPage: true });
|
||
if (testInfo) await testInfo.attach('debug-before-submit-click.png', { body: beforeSubmitScreenshot, contentType: 'image/png' });
|
||
|
||
// Wait for button to be visible and click with force (since normal click times out)
|
||
await expect(submitButton).toBeVisible({ timeout: 5000 });
|
||
await this.page.waitForTimeout(500); // Give form time to stabilize
|
||
await submitButton.click({ force: true });
|
||
|
||
// Wait for modal to close
|
||
await expect(this.page.locator('.create-event-modal, .modal')).toBeHidden({ timeout: 10000 });
|
||
|
||
// Take screenshot after modal closes
|
||
const afterCloseScreenshot = await this.page.screenshot({ fullPage: true });
|
||
if (testInfo) await testInfo.attach('debug-after-modal-close.png', { body: afterCloseScreenshot, contentType: 'image/png' });
|
||
|
||
// Wait for calendar to update
|
||
await this.page.waitForTimeout(2000);
|
||
await this.waitForCalendarLoad();
|
||
|
||
// Refresh the calendar data if needed
|
||
try {
|
||
const refreshButton = this.page.locator('button').filter({ hasText: /refresh|reload/i });
|
||
if (await refreshButton.count() > 0) {
|
||
await refreshButton.click();
|
||
await this.waitForCalendarLoad();
|
||
}
|
||
} catch {
|
||
// Refresh button doesn't exist, that's ok
|
||
}
|
||
}
|
||
|
||
async getEventByTitle(title: string): Promise<Locator> {
|
||
// Check current view to use appropriate selector
|
||
const isWeekView = await this.page.locator('.week-view-container').count() > 0;
|
||
|
||
if (isWeekView) {
|
||
// In week view, events use .week-event class, and text is in .event-title
|
||
return this.page.locator('.week-event').filter({ hasText: title });
|
||
} else {
|
||
// In month view, check for different possible selectors
|
||
return this.page.locator('.month-event, .calendar-event').filter({ hasText: title });
|
||
}
|
||
}
|
||
|
||
async dragEventToNewTime(eventTitle: string, targetTimeSlot: string) {
|
||
const event = await this.getEventByTitle(eventTitle);
|
||
const target = this.page.locator(`[data-time="${targetTimeSlot}"]`);
|
||
|
||
await event.dragTo(target);
|
||
await this.waitForCalendarLoad();
|
||
}
|
||
|
||
async openCalendarManagementModal() {
|
||
await this.page.getByRole('button', { name: '+ Add Calendar' }).click();
|
||
await expect(this.page.locator('.calendar-management-modal')).toBeVisible();
|
||
}
|
||
|
||
async createCalendar(calendarData: {
|
||
name: string;
|
||
color?: string;
|
||
type: 'caldav' | 'external';
|
||
url?: string;
|
||
}) {
|
||
await this.openCalendarManagementModal();
|
||
|
||
// Switch to appropriate tab
|
||
if (calendarData.type === 'caldav') {
|
||
await this.page.locator('.tab-button', { hasText: 'Create Calendar' }).click();
|
||
} else {
|
||
await this.page.locator('.tab-button', { hasText: 'Add External' }).click();
|
||
}
|
||
|
||
// Fill in calendar details
|
||
await this.page.getByLabel('Calendar Name').fill(calendarData.name);
|
||
|
||
if (calendarData.url) {
|
||
await this.page.getByLabel('Calendar URL').fill(calendarData.url);
|
||
}
|
||
|
||
if (calendarData.color) {
|
||
// Click color picker and select color
|
||
await this.page.locator('.color-picker').getByTestId(calendarData.color).click();
|
||
}
|
||
|
||
// Submit the form
|
||
const buttonText = calendarData.type === 'caldav' ? 'Create Calendar' : 'Add External Calendar';
|
||
await this.page.getByRole('button', { name: buttonText }).click();
|
||
|
||
// Wait for modal to close
|
||
await expect(this.page.locator('.calendar-management-modal')).toBeHidden();
|
||
}
|
||
|
||
async toggleCalendarVisibility(calendarName: string) {
|
||
// Look for the calendar in regular calendar list first, then external
|
||
const regularCalendarItem = this.page.locator('.calendar-list li').filter({ hasText: calendarName });
|
||
const externalCalendarItem = this.page.locator('.external-calendar-item').filter({ hasText: calendarName });
|
||
|
||
if (await regularCalendarItem.count() > 0) {
|
||
await regularCalendarItem.locator('input[type="checkbox"]').click();
|
||
} else if (await externalCalendarItem.count() > 0) {
|
||
await externalCalendarItem.locator('input[type="checkbox"]').click();
|
||
}
|
||
|
||
await this.waitForCalendarLoad();
|
||
}
|
||
|
||
async changeTheme(theme: string) {
|
||
await this.page.selectOption('.theme-selector-dropdown', theme);
|
||
|
||
// Wait for theme to apply
|
||
await this.page.waitForTimeout(500);
|
||
}
|
||
|
||
async changeStyle(style: string) {
|
||
await this.page.selectOption('.style-selector-dropdown', style);
|
||
|
||
// Wait for style to apply
|
||
await this.page.waitForTimeout(500);
|
||
}
|
||
|
||
async deleteEvent(eventTitle: string) {
|
||
const event = await this.getEventByTitle(eventTitle);
|
||
await expect(event).toBeVisible({ timeout: 5000 });
|
||
|
||
// Right-click to open context menu
|
||
await event.click({ button: 'right' });
|
||
|
||
// Wait for context menu and click delete
|
||
const deleteOption = this.page.getByText(/delete|remove/i);
|
||
await expect(deleteOption).toBeVisible({ timeout: 3000 });
|
||
await deleteOption.click();
|
||
|
||
// Confirm deletion if there's a confirmation dialog
|
||
const confirmButton = this.page.getByRole('button', { name: /delete|confirm|yes/i });
|
||
if (await confirmButton.count() > 0) {
|
||
await confirmButton.click();
|
||
}
|
||
|
||
// Wait for the event to be removed
|
||
await expect(event).toBeHidden({ timeout: 5000 });
|
||
await this.waitForCalendarLoad();
|
||
}
|
||
} |