Implement comprehensive frontend integration testing with Playwright
- 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>
This commit is contained in:
278
frontend/e2e/helpers/calendar-helpers.ts
Normal file
278
frontend/e2e/helpers/calendar-helpers.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user