Implement comprehensive frontend integration testing with Playwright
Some checks failed
Integration Tests / e2e-tests (push) Failing after 4s
Integration Tests / unit-tests (push) Failing after 1m1s

- 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:
Connor Johnstone
2025-09-08 11:54:40 -04:00
parent 927cd7d2bb
commit 7d00a2dadb
768 changed files with 647255 additions and 0 deletions

View 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();
}
}