import { Page, expect } from '@playwright/test'; export interface TestUser { email: string; password: string; isAdmin?: boolean; } export class TestHelpers { constructor(private page: Page) {} /** * Navigate to the home page */ async goToHomePage() { await this.page.goto('/'); await expect(this.page).toHaveTitle(/Dawarich/); } /** * Login with provided credentials */ async login(user: TestUser) { await this.page.goto('/users/sign_in'); // Fill in login form using actual Devise structure await this.page.getByLabel('Email').fill(user.email); await this.page.getByLabel('Password').fill(user.password); // Submit login await this.page.getByRole('button', { name: 'Log in' }).click(); // Wait for navigation to complete - use the same approach as working tests await this.page.waitForURL(/\/map/, { timeout: 10000 }); // Verify user is logged in by checking for email in navbar await expect(this.page.getByText(user.email)).toBeVisible({ timeout: 5000 }); } /** * Login with demo credentials */ async loginAsDemo() { await this.login({ email: 'demo@dawarich.app', password: 'password' }); } /** * Logout current user using actual navigation structure */ async logout() { // Open user dropdown using the actual navigation structure - use first() to avoid strict mode const userDropdown = this.page.locator('details').filter({ hasText: /@/ }).first(); await userDropdown.locator('summary').click(); // Use evaluate to trigger the logout form submission properly await this.page.evaluate(() => { const logoutLink = document.querySelector('a[href="/users/sign_out"]'); if (logoutLink) { // Create a form and submit it with DELETE method (Rails UJS style) const form = document.createElement('form'); form.action = '/users/sign_out'; form.method = 'post'; form.style.display = 'none'; // Add method override for DELETE const methodInput = document.createElement('input'); methodInput.type = 'hidden'; methodInput.name = '_method'; methodInput.value = 'delete'; form.appendChild(methodInput); // Add CSRF token const csrfToken = document.querySelector('meta[name="csrf-token"]'); if (csrfToken) { const csrfInput = document.createElement('input'); csrfInput.type = 'hidden'; csrfInput.name = 'authenticity_token'; const tokenValue = csrfToken.getAttribute('content'); if (tokenValue) { csrfInput.value = tokenValue; } form.appendChild(csrfInput); } document.body.appendChild(form); form.submit(); } }); // Wait for redirect and navigate to home to verify logout await this.page.waitForURL('/', { timeout: 10000 }); // Verify user is logged out - should see login options await expect(this.page.getByRole('link', { name: 'Sign in' })).toBeVisible(); } /** * Navigate to specific section using actual navigation structure */ async navigateTo(section: 'Map' | 'Trips' | 'Stats' | 'Points' | 'Visits' | 'Imports' | 'Exports' | 'Settings') { // Check if already on the target page const currentUrl = this.page.url(); const targetPath = section.toLowerCase(); if (section === 'Map' && (currentUrl.includes('/map') || currentUrl.endsWith('/'))) { // Already on map page, just navigate directly await this.page.goto('/map'); await this.page.waitForLoadState('networkidle'); return; } // Handle nested menu items that are in "My data" dropdown if (['Points', 'Visits', 'Imports', 'Exports'].includes(section)) { // Open "My data" dropdown - select the visible one (not the hidden mobile version) const myDataDropdown = this.page.locator('details').filter({ hasText: 'My data' }).and(this.page.locator(':visible')); await myDataDropdown.locator('summary').click(); // Handle special cases for visit links if (section === 'Visits') { await this.page.getByRole('link', { name: 'Visits & Places' }).click(); } else { await this.page.getByRole('link', { name: section }).click(); } } else if (section === 'Settings') { // Settings is accessed through user dropdown - use first() to avoid strict mode const userDropdown = this.page.locator('details').filter({ hasText: /@/ }).first(); await userDropdown.locator('summary').click(); await this.page.getByRole('link', { name: 'Settings' }).click(); } else { // Direct navigation items (Map, Trips, Stats) // Try to find the link, if not found, navigate directly const navLink = this.page.getByRole('link', { name: section }); try { await navLink.click({ timeout: 2000 }); } catch (error) { // If link not found, navigate directly to the page await this.page.goto(`/${targetPath}`); } } // Wait for page to load await this.page.waitForLoadState('networkidle'); } /** * Wait for map to be loaded and interactive */ async waitForMap() { // Wait for map container to be visible - the #map element is always present await expect(this.page.locator('#map')).toBeVisible(); // Wait for map controls to be available (indicates map is functional) await expect(this.page.getByRole('button', { name: 'Zoom in' })).toBeVisible(); // Wait a bit more for any async loading await this.page.waitForTimeout(500); } /** * Check if notification with specific text is visible */ async expectNotification(text: string, type: 'success' | 'error' | 'info' = 'success') { // Use actual flash message structure from Dawarich const notification = this.page.locator('#flash-messages .alert, #flash-messages div').filter({ hasText: text }); await expect(notification.first()).toBeVisible(); } /** * Upload a file using the file input */ async uploadFile(inputSelector: string, filePath: string) { const fileInput = this.page.locator(inputSelector); await fileInput.setInputFiles(filePath); } /** * Wait for background job to complete (polling approach) */ async waitForJobCompletion(jobName: string, timeout = 30000) { const startTime = Date.now(); while (Date.now() - startTime < timeout) { // Check if there's a completion notification in flash messages const completionNotification = this.page.locator('#flash-messages').filter({ hasText: new RegExp(jobName + '.*(completed|finished|done)', 'i') }); if (await completionNotification.isVisible()) { return; } // Wait before checking again await this.page.waitForTimeout(1000); } throw new Error(`Job "${jobName}" did not complete within ${timeout}ms`); } /** * Generate test file content for imports */ createTestGeoJSON(pointCount = 10): string { const features: any[] = []; const baseTime = Date.now() - (pointCount * 60 * 1000); // Points every minute for (let i = 0; i < pointCount; i++) { features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [-74.0060 + (i * 0.001), 40.7128 + (i * 0.001)] }, properties: { timestamp: Math.floor((baseTime + (i * 60 * 1000)) / 1000) } }); } return JSON.stringify({ type: 'FeatureCollection', features }); } /** * Check if element is visible on mobile viewports */ async isMobileViewport(): Promise { const viewport = this.page.viewportSize(); return viewport ? viewport.width < 768 : false; } /** * Handle mobile navigation (hamburger menu) using actual structure */ async openMobileNavigation() { if (await this.isMobileViewport()) { // Use actual mobile menu button structure from navbar const mobileMenuButton = this.page.locator('label[tabindex="0"]').or( this.page.locator('button').filter({ hasText: /menu/i }) ); if (await mobileMenuButton.isVisible()) { await mobileMenuButton.click(); } } } /** * Access account settings through user dropdown */ async goToAccountSettings() { const userDropdown = this.page.locator('details').filter({ hasText: /@/ }).first(); await userDropdown.locator('summary').click(); await this.page.getByRole('link', { name: 'Account' }).click(); await expect(this.page).toHaveURL(/\/users\/edit/); } /** * Check if user is admin by looking for admin indicator */ async isUserAdmin(): Promise { const adminStar = this.page.getByText('⭐️'); return await adminStar.isVisible(); } /** * Get current theme from HTML data attribute */ async getCurrentTheme(): Promise { return await this.page.getAttribute('html', 'data-theme'); } /** * Check if app is in self-hosted mode */ async isSelfHosted(): Promise { const selfHosted = await this.page.getAttribute('html', 'data-self-hosted'); return selfHosted === 'true'; } /** * Toggle theme using navbar theme button */ async toggleTheme() { // Theme button is an SVG inside a link const themeButton = this.page.locator('svg').locator('..').filter({ hasText: /path/ }); if (await themeButton.isVisible()) { await themeButton.click(); // Wait for theme change to take effect await this.page.waitForTimeout(500); } } /** * Check if notifications dropdown is available */ async hasNotifications(): Promise { const notificationButton = this.page.locator('svg').locator('..').filter({ hasText: /path.*stroke/ }); return await notificationButton.first().isVisible(); } /** * Open notifications dropdown */ async openNotifications() { if (await this.hasNotifications()) { const notificationButton = this.page.locator('svg').locator('..').filter({ hasText: /path.*stroke/ }).first(); await notificationButton.click(); } } /** * Generate new API key from account settings */ async generateNewApiKey() { await this.goToAccountSettings(); // Get current API key const currentApiKey = await this.page.locator('code').first().textContent(); // Click generate new API key button await this.page.getByRole('link', { name: 'Generate new API key' }).click(); // Wait for page to reload with new key await this.page.waitForLoadState('networkidle'); // Return new API key const newApiKey = await this.page.locator('code').first().textContent(); return { currentApiKey, newApiKey }; } /** * Access specific settings section */ async goToSettings(section?: 'Maps' | 'Background Jobs' | 'Users') { await this.navigateTo('Settings'); if (section) { // Click on the specific settings tab await this.page.getByRole('tab', { name: section }).click(); await this.page.waitForLoadState('networkidle'); } } } // Test data constants export const TEST_USERS = { DEMO: { email: 'demo@dawarich.app', password: 'password' }, ADMIN: { email: 'admin@dawarich.app', password: 'password', isAdmin: true } }; export const TEST_COORDINATES = { NYC: { lat: 40.7128, lon: -74.0060, name: 'New York City' }, LONDON: { lat: 51.5074, lon: -0.1278, name: 'London' }, TOKYO: { lat: 35.6762, lon: 139.6503, name: 'Tokyo' } };