mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-09 20:38:01 -05:00
Add E2E tests for Dawarich
This commit is contained in:
530
dawarich_user_scenarios.md
Normal file
530
dawarich_user_scenarios.md
Normal file
@@ -0,0 +1,530 @@
|
||||
# Dawarich User Scenarios Documentation
|
||||
|
||||
## Overview
|
||||
Dawarich is a self-hosted location history tracking application that allows users to import, visualize, and analyze their location data. This document describes all user scenarios for comprehensive test coverage.
|
||||
|
||||
## Application Context
|
||||
- **Purpose**: Self-hosted alternative to Google Timeline/Location History
|
||||
- **Tech Stack**: Rails 8, PostgreSQL, Hotwire (Turbo/Stimulus), Tailwind CSS with DaisyUI
|
||||
- **Key Features**: Location tracking, data visualization, import/export, statistics, visits detection
|
||||
- **Deployment**: Docker-based with self-hosted and cloud options
|
||||
|
||||
---
|
||||
|
||||
## 1. Authentication & User Management
|
||||
|
||||
### 1.1 User Registration (Non-Self-Hosted Mode)
|
||||
**Scenario**: New user registration process
|
||||
- **Entry Point**: Home page → Sign up link
|
||||
- **Steps**:
|
||||
1. Navigate to registration form
|
||||
2. Fill in email, password, password confirmation
|
||||
3. Complete CAPTCHA (if enabled)
|
||||
4. Submit registration
|
||||
5. Receive confirmation (if email verification enabled)
|
||||
- **Validation**: Email format, password strength, password confirmation match
|
||||
- **Success**: User created, redirected to sign-in or dashboard
|
||||
|
||||
### 1.2 User Sign In/Out
|
||||
**Scenario**: User authentication workflow
|
||||
- **Entry Point**: Home page → Sign in link
|
||||
- **Steps**:
|
||||
1. Navigate to sign-in form
|
||||
2. Enter email and password
|
||||
3. Optionally check "Remember me"
|
||||
4. Submit login
|
||||
5. Successful login redirects to map page
|
||||
- **Demo Mode**: Special demo credentials (demo@dawarich.app / password)
|
||||
- **Sign Out**: User can sign out from dropdown menu
|
||||
|
||||
### 1.3 Password Management
|
||||
**Scenario**: Password reset and change functionality
|
||||
- **Forgot Password**:
|
||||
1. Click "Forgot password" link
|
||||
2. Enter email address
|
||||
3. Receive reset email
|
||||
4. Follow reset link
|
||||
5. Set new password
|
||||
- **Change Password** (when signed in):
|
||||
1. Navigate to account settings
|
||||
2. Provide current password
|
||||
3. Enter new password and confirmation
|
||||
4. Save changes
|
||||
|
||||
### 1.4 Account Settings
|
||||
**Scenario**: User account management
|
||||
- **Entry Point**: User dropdown → Account
|
||||
- **Actions**:
|
||||
1. Update email address (requires current password)
|
||||
2. Change password
|
||||
3. View API key
|
||||
4. Generate new API key
|
||||
5. Theme selection (light/dark)
|
||||
- **Self-Hosted**: Limited registration options
|
||||
|
||||
---
|
||||
|
||||
## 2. Map Functionality & Visualization
|
||||
|
||||
### 2.1 Main Map Interface
|
||||
**Scenario**: Core location data visualization
|
||||
- **Entry Point**: Primary navigation → Map
|
||||
- **Features**:
|
||||
1. Interactive Leaflet map with multiple tile layers
|
||||
2. Time range selector (date/time inputs)
|
||||
3. Quick time range buttons (Today, Last 7 days, Last month)
|
||||
4. Navigation arrows for day-by-day browsing
|
||||
5. Real-time distance and points count display
|
||||
|
||||
### 2.2 Map Layers & Controls
|
||||
**Scenario**: Map customization and layer management
|
||||
- **Base Layers**:
|
||||
1. Switch between OpenStreetMap and OpenTopo
|
||||
2. Custom tile layer configuration
|
||||
- **Overlay Layers**:
|
||||
1. Toggle points display
|
||||
2. Toggle route lines
|
||||
3. Toggle heatmap
|
||||
4. Toggle fog of war
|
||||
5. Toggle areas
|
||||
6. Toggle visits
|
||||
- **Layer Control**: Expandable/collapsible layer panel
|
||||
|
||||
### 2.3 Map Data Display
|
||||
**Scenario**: Location data visualization options
|
||||
- **Points Rendering**:
|
||||
1. Raw mode (all points)
|
||||
2. Simplified mode (filtered by time/distance)
|
||||
3. Point clicking reveals details popup
|
||||
4. Battery level, altitude, velocity display
|
||||
- **Routes**:
|
||||
1. Polyline connections between points
|
||||
2. Speed-colored routes option
|
||||
3. Configurable route opacity
|
||||
4. Route segment distance display
|
||||
|
||||
### 2.4 Map Settings & Configuration
|
||||
**Scenario**: Map behavior customization
|
||||
- **Settings Available**:
|
||||
1. Route opacity (0-100%)
|
||||
2. Meters between routes (distance threshold)
|
||||
3. Minutes between routes (time threshold)
|
||||
4. Fog of war radius
|
||||
5. Speed color scale customization
|
||||
6. Points rendering mode
|
||||
- **Help Modals**: Contextual help for each setting
|
||||
|
||||
---
|
||||
|
||||
## 3. Location Data Import
|
||||
|
||||
### 3.1 Manual File Import
|
||||
**Scenario**: Import location data from various sources
|
||||
- **Entry Point**: Navigation → My data → Imports
|
||||
- **Supported Sources**:
|
||||
1. Google Semantic History (JSON files)
|
||||
2. Google Records (Records.json)
|
||||
3. Google Phone Takeout (mobile device JSON)
|
||||
4. OwnTracks (.rec files)
|
||||
5. GeoJSON files
|
||||
6. GPX track files
|
||||
- **Process**:
|
||||
1. Select source type
|
||||
2. Choose file(s) via file picker
|
||||
3. Upload and process (background job)
|
||||
4. Receive completion notification
|
||||
|
||||
### 3.2 Automatic File Watching
|
||||
**Scenario**: Automatic import from watched directories
|
||||
- **Setup**: Files placed in `/tmp/imports/watched/USER@EMAIL.TLD/`
|
||||
- **Process**: System scans hourly for new files
|
||||
- **Supported Formats**: GPX, JSON, REC files
|
||||
- **Notification**: User receives import completion notifications
|
||||
|
||||
### 3.3 Photo Integration Import
|
||||
**Scenario**: Import location data from photo EXIF data
|
||||
- **Immich Integration**:
|
||||
1. Configure Immich URL and API key in settings
|
||||
2. Trigger import job
|
||||
3. System extracts GPS data from photos
|
||||
4. Creates location points from photo metadata
|
||||
- **Photoprism Integration**:
|
||||
1. Configure Photoprism URL and API key
|
||||
2. Similar process to Immich
|
||||
3. Supports different date ranges
|
||||
|
||||
### 3.4 Import Management
|
||||
**Scenario**: View and manage import history
|
||||
- **Import List**: View all imports with status
|
||||
- **Import Details**: Points count, processing status, errors
|
||||
- **Import Actions**: View details, delete imports
|
||||
- **Progress Tracking**: Real-time progress updates via WebSocket
|
||||
|
||||
---
|
||||
|
||||
## 4. Data Export
|
||||
|
||||
### 4.1 Export Creation
|
||||
**Scenario**: Export location data in various formats
|
||||
- **Entry Point**: Navigation → My data → Exports
|
||||
- **Export Types**:
|
||||
1. GeoJSON format (default)
|
||||
2. GPX format
|
||||
3. Complete user data archive (ZIP)
|
||||
- **Process**:
|
||||
1. Select export format
|
||||
2. Choose date range (optional)
|
||||
3. Submit export request
|
||||
4. Background processing
|
||||
5. Notification when complete
|
||||
|
||||
### 4.2 Export Management
|
||||
**Scenario**: Manage created exports
|
||||
- **Export List**: View all exports with details
|
||||
- **Export Actions**:
|
||||
1. Download completed exports
|
||||
2. Delete old exports
|
||||
3. View export status
|
||||
- **File Information**: Size, creation date, download links
|
||||
|
||||
### 4.3 Complete Data Export
|
||||
**Scenario**: Export all user data for backup/migration
|
||||
- **Trigger**: Settings → Users → Export data
|
||||
- **Content**: All user data, settings, files in ZIP format
|
||||
- **Use Case**: Account migration, data backup
|
||||
- **Process**: Background job, notification on completion
|
||||
|
||||
---
|
||||
|
||||
## 5. Statistics & Analytics
|
||||
|
||||
### 5.1 Statistics Dashboard
|
||||
**Scenario**: View travel statistics and analytics
|
||||
- **Entry Point**: Navigation → Stats
|
||||
- **Key Metrics**:
|
||||
1. Total distance traveled
|
||||
2. Total tracked points
|
||||
3. Countries visited
|
||||
4. Cities visited
|
||||
5. Reverse geocoding statistics
|
||||
- **Display**: Cards with highlighted numbers and units
|
||||
|
||||
### 5.2 Yearly/Monthly Breakdown
|
||||
**Scenario**: Detailed statistics by time period
|
||||
- **View Options**:
|
||||
1. Statistics by year
|
||||
2. Monthly breakdown within years
|
||||
3. Distance traveled per period
|
||||
4. Points tracked per period
|
||||
- **Actions**: Update statistics (background job)
|
||||
|
||||
### 5.3 Statistics Management
|
||||
**Scenario**: Update and manage statistics
|
||||
- **Manual Updates**:
|
||||
1. Update all statistics
|
||||
2. Update specific year/month
|
||||
3. Background job processing
|
||||
- **Automatic Updates**: Triggered by data imports
|
||||
|
||||
---
|
||||
|
||||
## 6. Trips Management
|
||||
|
||||
### 6.1 Trip Creation
|
||||
**Scenario**: Create and manage travel trips
|
||||
- **Entry Point**: Navigation → Trips → New trip
|
||||
- **Trip Properties**:
|
||||
1. Trip name
|
||||
2. Start date/time
|
||||
3. End date/time
|
||||
4. Notes (rich text)
|
||||
- **Validation**: Date ranges, required fields
|
||||
|
||||
### 6.2 Trip Visualization
|
||||
**Scenario**: View trip details and route
|
||||
- **Trip View**:
|
||||
1. Interactive map with trip route
|
||||
2. Trip statistics (distance, duration)
|
||||
3. Countries visited during trip
|
||||
4. Photo integration (if configured)
|
||||
- **Photo Display**: Grid layout with links to photo sources
|
||||
|
||||
### 6.3 Trip Management
|
||||
**Scenario**: Edit and manage existing trips
|
||||
- **Trip List**: Paginated view of all trips
|
||||
- **Trip Actions**:
|
||||
1. Edit trip details
|
||||
2. Delete trips
|
||||
3. View trip details
|
||||
- **Background Processing**: Distance and route calculations
|
||||
|
||||
---
|
||||
|
||||
## 7. Visits & Places (Beta Feature)
|
||||
|
||||
### 7.1 Visit Suggestions
|
||||
**Scenario**: Automatic visit detection and suggestions
|
||||
- **Process**: Background job analyzes location data
|
||||
- **Detection**: Identifies places where user spent time
|
||||
- **Suggestions**: Creates suggested visits for review
|
||||
- **Notifications**: User receives visit suggestion notifications
|
||||
|
||||
### 7.2 Visit Management
|
||||
**Scenario**: Review and manage visit suggestions
|
||||
- **Entry Point**: Navigation → My data → Visits & Places
|
||||
- **Visit States**:
|
||||
1. Suggested (pending review)
|
||||
2. Confirmed (accepted)
|
||||
3. Declined (rejected)
|
||||
- **Actions**: Confirm, decline, or edit visits
|
||||
- **Filtering**: View by status, order by date
|
||||
|
||||
### 7.3 Places Management
|
||||
**Scenario**: Manage detected places
|
||||
- **Place List**: All places created by visit suggestions
|
||||
- **Place Details**: Name, coordinates, creation date
|
||||
- **Actions**: Delete places (deletes associated visits)
|
||||
- **Integration**: Places linked to visits
|
||||
|
||||
### 7.4 Areas Creation
|
||||
**Scenario**: Create custom areas for visit detection
|
||||
- **Map Interface**: Draw areas on map
|
||||
- **Area Properties**:
|
||||
1. Name
|
||||
2. Radius
|
||||
3. Coordinates (center point)
|
||||
- **Purpose**: Improve visit detection accuracy
|
||||
|
||||
---
|
||||
|
||||
## 8. Points Management
|
||||
|
||||
### 8.1 Points List
|
||||
**Scenario**: View and manage individual location points
|
||||
- **Entry Point**: Navigation → My data → Points
|
||||
- **Display**: Paginated table with point details
|
||||
- **Point Information**:
|
||||
1. Timestamp
|
||||
2. Coordinates
|
||||
3. Accuracy
|
||||
4. Source import
|
||||
- **Filtering**: Date range, import source
|
||||
|
||||
### 8.2 Point Actions
|
||||
**Scenario**: Individual point management
|
||||
- **Point Details**: Click point for popup with full details
|
||||
- **Actions**:
|
||||
1. Delete individual points
|
||||
2. Bulk delete points
|
||||
3. View point source
|
||||
- **Map Integration**: Points clickable on map
|
||||
|
||||
---
|
||||
|
||||
## 9. Notifications System
|
||||
|
||||
### 9.1 Notification Types
|
||||
**Scenario**: System notifications for various events
|
||||
- **Import Notifications**:
|
||||
1. Import completed
|
||||
2. Import failed
|
||||
3. Import progress updates
|
||||
- **Export Notifications**:
|
||||
1. Export completed
|
||||
2. Export failed
|
||||
- **System Notifications**:
|
||||
1. Visit suggestions available
|
||||
2. Statistics updates completed
|
||||
3. Background job failures
|
||||
|
||||
### 9.2 Notification Management
|
||||
**Scenario**: View and manage notifications
|
||||
- **Entry Point**: Bell icon in navigation
|
||||
- **Notification List**: All notifications with timestamps
|
||||
- **Actions**:
|
||||
1. Mark as read
|
||||
2. Mark all as read
|
||||
3. Delete notifications
|
||||
4. Delete all notifications
|
||||
- **Display**: Badges for unread count
|
||||
|
||||
---
|
||||
|
||||
## 10. Settings & Configuration
|
||||
|
||||
### 10.1 Integration Settings
|
||||
**Scenario**: Configure external service integrations
|
||||
- **Entry Point**: Navigation → Settings → Integrations
|
||||
- **Immich Integration**:
|
||||
1. Configure Immich URL
|
||||
2. Set API key
|
||||
3. Test connection
|
||||
- **Photoprism Integration**:
|
||||
1. Configure Photoprism URL
|
||||
2. Set API key
|
||||
3. Test connection
|
||||
|
||||
### 10.2 Map Settings
|
||||
**Scenario**: Configure map appearance and behavior
|
||||
- **Entry Point**: Settings → Map
|
||||
- **Options**:
|
||||
1. Custom tile layer URL
|
||||
2. Map layer name
|
||||
3. Distance unit (km/miles)
|
||||
4. Tile usage statistics
|
||||
- **Preview**: Real-time map preview
|
||||
|
||||
### 10.3 User Settings
|
||||
**Scenario**: Personal preferences and account settings
|
||||
- **Theme**: Light/dark mode toggle
|
||||
- **API Key**: View and regenerate API key
|
||||
- **Visits Settings**: Enable/disable visit suggestions
|
||||
- **Route Settings**: Default route appearance
|
||||
|
||||
---
|
||||
|
||||
## 11. Admin Features (Self-Hosted Only)
|
||||
|
||||
### 11.1 User Management
|
||||
**Scenario**: Admin user management in self-hosted mode
|
||||
- **Entry Point**: Settings → Users (admin only)
|
||||
- **User Actions**:
|
||||
1. Create new users
|
||||
2. Edit user details
|
||||
3. Delete users
|
||||
4. View user statistics
|
||||
- **User Creation**: Email and password setup
|
||||
|
||||
### 11.2 Background Jobs Management
|
||||
**Scenario**: Admin control over background processing
|
||||
- **Entry Point**: Settings → Background Jobs
|
||||
- **Job Types**:
|
||||
1. Reverse geocoding jobs
|
||||
2. Statistics calculation
|
||||
3. Visit suggestion jobs
|
||||
- **Actions**: Start/stop background jobs, view job status
|
||||
|
||||
### 11.3 System Administration
|
||||
**Scenario**: System-level administration
|
||||
- **Sidekiq Dashboard**: Background job monitoring
|
||||
- **System Settings**: Global configuration options
|
||||
- **User Data Management**: Export/import user data
|
||||
|
||||
---
|
||||
|
||||
## 12. API Functionality
|
||||
|
||||
### 12.1 Location Data API
|
||||
**Scenario**: Programmatic location data submission
|
||||
- **Endpoints**: RESTful API for location data
|
||||
- **Authentication**: API key based
|
||||
- **Supported Apps**:
|
||||
1. Dawarich iOS app
|
||||
2. Overland
|
||||
3. OwnTracks
|
||||
4. GPSLogger
|
||||
5. Custom applications
|
||||
|
||||
### 12.2 Data Retrieval API
|
||||
**Scenario**: Retrieve location data via API
|
||||
- **Use Cases**: Third-party integrations, mobile apps
|
||||
- **Data Formats**: JSON, GeoJSON
|
||||
- **Authentication**: API key required
|
||||
|
||||
---
|
||||
|
||||
## 13. Error Handling & Edge Cases
|
||||
|
||||
### 13.1 Import Errors
|
||||
**Scenario**: Handle various import failure scenarios
|
||||
- **File Format Errors**: Unsupported or corrupted files
|
||||
- **Processing Errors**: Background job failures
|
||||
- **Network Errors**: Failed downloads or API calls
|
||||
- **User Feedback**: Error notifications with details
|
||||
|
||||
### 13.2 System Errors
|
||||
**Scenario**: Handle system-level errors
|
||||
- **Database Errors**: Connection issues, constraints
|
||||
- **Storage Errors**: File system issues
|
||||
- **Memory Errors**: Large data processing
|
||||
- **User Experience**: Graceful error messages
|
||||
|
||||
### 13.3 Data Validation
|
||||
**Scenario**: Validate user input and data integrity
|
||||
- **Coordinate Validation**: Valid latitude/longitude
|
||||
- **Time Validation**: Logical timestamp values
|
||||
- **File Validation**: Supported formats and sizes
|
||||
- **User Input**: Form validation and sanitization
|
||||
|
||||
---
|
||||
|
||||
## 14. Performance & Scalability
|
||||
|
||||
### 14.1 Large Dataset Handling
|
||||
**Scenario**: Handle users with large amounts of location data
|
||||
- **Map Performance**: Efficient rendering of many points
|
||||
- **Data Processing**: Batch processing for imports
|
||||
- **Memory Management**: Streaming for large files
|
||||
- **User Experience**: Progress indicators, pagination
|
||||
|
||||
### 14.2 Background Processing
|
||||
**Scenario**: Asynchronous task handling
|
||||
- **Job Queues**: Sidekiq for background jobs
|
||||
- **Progress Tracking**: Real-time job status
|
||||
- **Error Recovery**: Retry mechanisms
|
||||
- **User Feedback**: Job completion notifications
|
||||
|
||||
---
|
||||
|
||||
## 15. Mobile & Responsive Design
|
||||
|
||||
### 15.1 Mobile Interface
|
||||
**Scenario**: Mobile-optimized user experience
|
||||
- **Responsive Design**: Mobile-first approach
|
||||
- **Touch Interactions**: Map gestures, mobile-friendly controls
|
||||
- **Mobile Navigation**: Collapsible menus
|
||||
- **Performance**: Optimized for mobile devices
|
||||
|
||||
### 15.2 Cross-Platform Compatibility
|
||||
**Scenario**: Consistent experience across devices
|
||||
- **Browser Support**: Modern browser compatibility
|
||||
- **Device Support**: Desktop, tablet, mobile
|
||||
- **Feature Parity**: Full functionality across platforms
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios Priority
|
||||
|
||||
### High Priority (Core Functionality)
|
||||
1. User authentication (sign in/out)
|
||||
2. Map visualization with basic controls
|
||||
3. Data import (at least one source type)
|
||||
4. Basic settings configuration
|
||||
5. Point display and interaction
|
||||
|
||||
### Medium Priority (Extended Features)
|
||||
1. Trip management
|
||||
2. Visit suggestions and management
|
||||
3. Data export
|
||||
4. Statistics viewing
|
||||
5. Notification system
|
||||
|
||||
### Low Priority (Advanced Features)
|
||||
1. Admin functions
|
||||
2. API functionality
|
||||
3. Complex map settings
|
||||
4. Background job management
|
||||
5. Error handling edge cases
|
||||
|
||||
---
|
||||
|
||||
## Notes for Test Implementation
|
||||
|
||||
1. **Test Data**: Use factory-generated test data for consistency
|
||||
2. **API Testing**: Include both UI and API endpoint testing
|
||||
3. **Background Jobs**: Test asynchronous processing
|
||||
4. **File Handling**: Test various file formats and sizes
|
||||
5. **Responsive Testing**: Include mobile viewport testing
|
||||
6. **Performance Testing**: Test with large datasets
|
||||
7. **Error Scenarios**: Include negative test cases
|
||||
8. **Browser Compatibility**: Test across different browsers
|
||||
296
e2e/README.md
Normal file
296
e2e/README.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# Dawarich E2E Test Suite
|
||||
|
||||
This directory contains comprehensive end-to-end tests for the Dawarich location tracking application using Playwright.
|
||||
|
||||
## Test Structure
|
||||
|
||||
The test suite is organized into several test files that cover different aspects of the application:
|
||||
|
||||
### Core Test Files
|
||||
|
||||
- **`auth.spec.ts`** - Authentication and user management tests
|
||||
- **`map.spec.ts`** - Map functionality and visualization tests
|
||||
- **`imports.spec.ts`** - Data import functionality tests
|
||||
- **`settings.spec.ts`** - Application settings and configuration tests
|
||||
- **`navigation.spec.ts`** - Navigation and UI interaction tests
|
||||
- **`trips.spec.ts`** - Trip management and analysis tests
|
||||
|
||||
### Helper Files
|
||||
|
||||
- **`fixtures/test-helpers.ts`** - Reusable test utilities and helper functions
|
||||
- **`global-setup.ts`** - Global test environment setup
|
||||
- **`example.spec.ts`** - Basic example test (can be removed)
|
||||
|
||||
## Configuration
|
||||
|
||||
- **`playwright.config.ts`** - Playwright configuration with browser setup, timeouts, and test settings
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. Node.js and npm installed
|
||||
2. Dawarich application running locally on port 3000 (or configured port)
|
||||
3. Test environment properly configured
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install Playwright
|
||||
npm install -D @playwright/test
|
||||
|
||||
# Install browsers (first time only)
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm run test:e2e
|
||||
|
||||
# Run tests in headed mode (see browser)
|
||||
npx playwright test --headed
|
||||
|
||||
# Run specific test file
|
||||
npx playwright test auth.spec.ts
|
||||
|
||||
# Run tests with specific browser
|
||||
npx playwright test --project=chromium
|
||||
|
||||
# Run tests in debug mode
|
||||
npx playwright test --debug
|
||||
```
|
||||
|
||||
### Test Reports
|
||||
|
||||
```bash
|
||||
# Generate HTML report
|
||||
npx playwright show-report
|
||||
|
||||
# View last test results
|
||||
npx playwright show-report
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### High Priority Features (✅ Covered)
|
||||
- User authentication (login/logout)
|
||||
- Map visualization and interaction
|
||||
- Data import from various sources
|
||||
- Basic settings configuration
|
||||
- Navigation and UI interactions
|
||||
- Trip management and creation
|
||||
|
||||
### Medium Priority Features (✅ Covered)
|
||||
- Settings management (integrations, map config)
|
||||
- Mobile responsive behavior
|
||||
- Data visualization and statistics
|
||||
- File upload handling
|
||||
- User preferences and customization
|
||||
|
||||
### Low Priority Features (✅ Covered)
|
||||
- Advanced trip analysis
|
||||
- Performance testing
|
||||
- Error handling
|
||||
- Accessibility testing
|
||||
- Keyboard navigation
|
||||
|
||||
## Test Patterns
|
||||
|
||||
### Helper Functions
|
||||
|
||||
Use the `TestHelpers` class for common operations:
|
||||
|
||||
```typescript
|
||||
import { TestHelpers } from './fixtures/test-helpers';
|
||||
|
||||
test('example', async ({ page }) => {
|
||||
const helpers = new TestHelpers(page);
|
||||
await helpers.loginAsDemo();
|
||||
await helpers.navigateTo('Map');
|
||||
await helpers.waitForMap();
|
||||
});
|
||||
```
|
||||
|
||||
### Test Organization
|
||||
|
||||
Tests are organized with descriptive `test.describe` blocks:
|
||||
|
||||
```typescript
|
||||
test.describe('Feature Name', () => {
|
||||
test.describe('Sub-feature', () => {
|
||||
test('should do something specific', async ({ page }) => {
|
||||
// Test implementation
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Assertions
|
||||
|
||||
Use clear, descriptive assertions:
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
await expect(page.getByRole('heading', { name: 'Map' })).toBeVisible();
|
||||
|
||||
// Better with context
|
||||
await expect(page.getByRole('button', { name: 'Create Trip' })).toBeVisible();
|
||||
```
|
||||
|
||||
## Configuration Notes
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The tests use these environment variables:
|
||||
|
||||
- `BASE_URL` - Base URL for the application (defaults to http://localhost:3000)
|
||||
- `CI` - Set to true in CI environments
|
||||
|
||||
### Test Data
|
||||
|
||||
Tests use the demo user credentials:
|
||||
- Email: `demo@dawarich.app`
|
||||
- Password: `password`
|
||||
|
||||
### Browser Configuration
|
||||
|
||||
Tests run on:
|
||||
- Chromium (primary)
|
||||
- Firefox
|
||||
- WebKit (Safari)
|
||||
- Mobile Chrome
|
||||
- Mobile Safari
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Test Independence
|
||||
|
||||
Each test should be independent and able to run in isolation:
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const helpers = new TestHelpers(page);
|
||||
await helpers.loginAsDemo();
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Robust Selectors
|
||||
|
||||
Use semantic selectors that won't break easily:
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
await page.getByRole('button', { name: 'Save' });
|
||||
await page.getByLabel('Email');
|
||||
|
||||
// Avoid
|
||||
await page.locator('.btn-primary');
|
||||
await page.locator('#email-input');
|
||||
```
|
||||
|
||||
### 3. Wait for Conditions
|
||||
|
||||
Wait for specific conditions rather than arbitrary timeouts:
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByText('Success')).toBeVisible();
|
||||
|
||||
// Avoid
|
||||
await page.waitForTimeout(5000);
|
||||
```
|
||||
|
||||
### 4. Handle Optional Elements
|
||||
|
||||
Use conditional logic for elements that may not exist:
|
||||
|
||||
```typescript
|
||||
const deleteButton = page.getByRole('button', { name: 'Delete' });
|
||||
if (await deleteButton.isVisible()) {
|
||||
await deleteButton.click();
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Mobile Testing
|
||||
|
||||
Include mobile viewport testing:
|
||||
|
||||
```typescript
|
||||
test('should work on mobile', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
// Test implementation
|
||||
});
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Adding New Tests
|
||||
|
||||
1. Create tests in the appropriate spec file
|
||||
2. Use descriptive test names
|
||||
3. Follow the existing patterns
|
||||
4. Update this README if adding new test files
|
||||
|
||||
### Updating Selectors
|
||||
|
||||
When the application UI changes:
|
||||
1. Update selectors in helper functions first
|
||||
2. Run tests to identify breaking changes
|
||||
3. Update individual test files as needed
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- Tests include performance checks for critical paths
|
||||
- Map loading times are monitored
|
||||
- Navigation speed is tested
|
||||
- Large dataset handling is verified
|
||||
|
||||
## Debugging
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Server not ready** - Ensure Dawarich is running on the correct port
|
||||
2. **Element not found** - Check if UI has changed or element is conditionally rendered
|
||||
3. **Timeouts** - Verify network conditions and increase timeouts if needed
|
||||
4. **Map not loading** - Ensure map dependencies are available
|
||||
|
||||
### Debug Tips
|
||||
|
||||
```bash
|
||||
# Run with debug flag
|
||||
npx playwright test --debug
|
||||
|
||||
# Run specific test with trace
|
||||
npx playwright test auth.spec.ts --trace on
|
||||
|
||||
# Record video on failure
|
||||
npx playwright test --video retain-on-failure
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
The test suite is configured for CI/CD with:
|
||||
- Automatic retry on failure
|
||||
- Parallel execution control
|
||||
- Artifact collection (screenshots, videos, traces)
|
||||
- HTML report generation
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new tests:
|
||||
1. Follow the existing patterns
|
||||
2. Add appropriate test coverage
|
||||
3. Update documentation
|
||||
4. Ensure tests pass in all browsers
|
||||
5. Consider mobile and accessibility aspects
|
||||
|
||||
## Support
|
||||
|
||||
For issues with the test suite:
|
||||
1. Check the test logs and reports
|
||||
2. Verify application state
|
||||
3. Review recent changes
|
||||
4. Check browser compatibility
|
||||
509
e2e/auth.spec.ts
Normal file
509
e2e/auth.spec.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { TestHelpers, TEST_USERS } from './fixtures/test-helpers';
|
||||
|
||||
test.describe('Authentication', () => {
|
||||
let helpers: TestHelpers;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
helpers = new TestHelpers(page);
|
||||
});
|
||||
|
||||
test.describe('Login and Logout', () => {
|
||||
test('should display login page correctly', async ({ page }) => {
|
||||
await page.goto('/users/sign_in');
|
||||
|
||||
// Check page elements based on actual Devise view
|
||||
await expect(page).toHaveTitle(/Dawarich/);
|
||||
await expect(page.getByRole('heading', { name: 'Login now' })).toBeVisible();
|
||||
await expect(page.getByLabel('Email')).toBeVisible();
|
||||
await expect(page.getByLabel('Password')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Forgot your password?' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show demo credentials in demo environment', async ({ page }) => {
|
||||
await page.goto('/users/sign_in');
|
||||
|
||||
// Check if demo credentials are shown (they may not be in test environment)
|
||||
const demoCredentials = page.getByText('demo@dawarich.app');
|
||||
if (await demoCredentials.isVisible()) {
|
||||
await expect(demoCredentials).toBeVisible();
|
||||
await expect(page.getByText('password').nth(1)).toBeVisible(); // Second "password" text
|
||||
}
|
||||
});
|
||||
|
||||
test('should login with valid credentials', async ({ page }) => {
|
||||
await helpers.loginAsDemo();
|
||||
|
||||
// Verify successful login - should redirect to map
|
||||
await expect(page).toHaveURL(/\/map/);
|
||||
await expect(page.getByText(TEST_USERS.DEMO.email)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should reject invalid credentials', async ({ page }) => {
|
||||
await page.goto('/users/sign_in');
|
||||
|
||||
await page.getByLabel('Email').fill('invalid@email.com');
|
||||
await page.getByLabel('Password').fill('wrongpassword');
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
|
||||
// Should stay on login page and show error
|
||||
await expect(page).toHaveURL(/\/users\/sign_in/);
|
||||
// Devise shows error messages - look for error indication
|
||||
const errorMessage = page.locator('#error_explanation, .alert, .flash').filter({ hasText: /invalid/i });
|
||||
if (await errorMessage.isVisible()) {
|
||||
await expect(errorMessage).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should remember user when "Remember me" is checked', async ({ page }) => {
|
||||
await page.goto('/users/sign_in');
|
||||
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||
|
||||
// Look for remember me checkbox - use getByRole to target the actual checkbox
|
||||
const rememberCheckbox = page.getByRole('checkbox', { name: 'Remember me' });
|
||||
|
||||
if (await rememberCheckbox.isVisible()) {
|
||||
await rememberCheckbox.check();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
|
||||
// Wait for redirect with longer timeout
|
||||
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||
|
||||
// Check for remember token cookie
|
||||
const cookies = await page.context().cookies();
|
||||
const hasPersistentCookie = cookies.some(cookie =>
|
||||
cookie.name.includes('remember') || cookie.name.includes('session')
|
||||
);
|
||||
expect(hasPersistentCookie).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should logout successfully', async ({ page }) => {
|
||||
await helpers.loginAsDemo();
|
||||
|
||||
// Open user dropdown using the actual navigation structure
|
||||
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||
await userDropdown.locator('summary').click();
|
||||
|
||||
// Use evaluate to trigger the logout form submission properly
|
||||
await 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 page.waitForURL('/', { timeout: 10000 });
|
||||
|
||||
// Verify user is logged out - should see login options
|
||||
await expect(page.getByRole('link', { name: 'Sign in' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should redirect to login when accessing protected pages while logged out', async ({ page }) => {
|
||||
await page.goto('/map');
|
||||
|
||||
// Should redirect to login
|
||||
await expect(page).toHaveURL(/\/users\/sign_in/);
|
||||
});
|
||||
});
|
||||
|
||||
// NOTE: Update TEST_USERS in fixtures/test-helpers.ts with correct credentials
|
||||
// that match your localhost:3000 server setup
|
||||
test.describe('Password Management', () => {
|
||||
test('should display forgot password form', async ({ page }) => {
|
||||
await page.goto('/users/sign_in');
|
||||
await page.getByRole('link', { name: 'Forgot your password?' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/users\/password\/new/);
|
||||
await expect(page.getByRole('heading', { name: 'Forgot your password?' })).toBeVisible();
|
||||
await expect(page.getByLabel('Email')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Send me reset password instructions' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle password reset request', async ({ page }) => {
|
||||
await page.goto('/users/password/new');
|
||||
|
||||
// Fill the email but don't submit to avoid sending actual reset emails
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
|
||||
// Verify the form elements exist and are functional
|
||||
await expect(page.getByRole('button', { name: 'Send me reset password instructions' })).toBeVisible();
|
||||
await expect(page.getByLabel('Email')).toHaveValue(TEST_USERS.DEMO.email);
|
||||
|
||||
// Test form validation by clearing email and checking if button is still clickable
|
||||
await page.getByLabel('Email').fill('');
|
||||
await expect(page.getByRole('button', { name: 'Send me reset password instructions' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should change password when logged in', async ({ page }) => {
|
||||
// Manual login for this test
|
||||
await page.goto('/users/sign_in');
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||
|
||||
// Navigate to account settings through user dropdown
|
||||
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||
await userDropdown.locator('summary').click();
|
||||
await page.getByRole('link', { name: 'Account' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/users\/edit/);
|
||||
|
||||
// Check password change form is available - be more specific with selectors
|
||||
await expect(page.locator('input[id="user_password"]')).toBeVisible();
|
||||
await expect(page.getByLabel('Current password')).toBeVisible();
|
||||
|
||||
// Test filling the form but don't submit to avoid changing the password
|
||||
await page.locator('input[id="user_password"]').fill('newpassword123');
|
||||
await page.getByLabel('Current password').fill(TEST_USERS.DEMO.password);
|
||||
|
||||
// Verify the form can be filled and update button is present
|
||||
await expect(page.getByRole('button', { name: 'Update' })).toBeVisible();
|
||||
|
||||
// Clear the password fields to avoid changing credentials
|
||||
await page.locator('input[id="user_password"]').fill('');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Account Settings', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Fresh login for each test in this describe block
|
||||
await page.goto('/users/sign_in');
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('should display account settings page', async ({ page }) => {
|
||||
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||
await userDropdown.locator('summary').click();
|
||||
await page.getByRole('link', { name: 'Account' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/users\/edit/);
|
||||
await expect(page.getByRole('heading', { name: 'Edit your account!' })).toBeVisible();
|
||||
await expect(page.getByLabel('Email')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should update email address with current password', async ({ page }) => {
|
||||
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||
await userDropdown.locator('summary').click();
|
||||
await page.getByRole('link', { name: 'Account' }).click();
|
||||
|
||||
// Test that we can fill the form, but don't actually submit to avoid changing credentials
|
||||
await page.getByLabel('Email').fill('newemail@test.com');
|
||||
await page.getByLabel('Current password').fill(TEST_USERS.DEMO.password);
|
||||
|
||||
// Verify the form elements are present and fillable, but don't submit
|
||||
await expect(page.getByRole('button', { name: 'Update' })).toBeVisible();
|
||||
|
||||
// Reset the email field to avoid confusion
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
});
|
||||
|
||||
test('should view API key in settings', async ({ page }) => {
|
||||
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||
await userDropdown.locator('summary').click();
|
||||
await page.getByRole('link', { name: 'Account' }).click();
|
||||
|
||||
// API key should be visible in the account section
|
||||
await expect(page.getByText('Use this API key')).toBeVisible();
|
||||
await expect(page.locator('code').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should generate new API key', async ({ page }) => {
|
||||
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||
await userDropdown.locator('summary').click();
|
||||
await page.getByRole('link', { name: 'Account' }).click();
|
||||
|
||||
// Get current API key
|
||||
const currentApiKey = await page.locator('code').first().textContent();
|
||||
|
||||
// Verify the generate new API key link exists but don't click it to avoid changing the key
|
||||
const generateKeyLink = page.getByRole('link', { name: 'Generate new API key' });
|
||||
await expect(generateKeyLink).toBeVisible();
|
||||
|
||||
// Verify the API key is displayed
|
||||
await expect(page.locator('code').first()).toBeVisible();
|
||||
expect(currentApiKey).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should change theme', async ({ page }) => {
|
||||
// Theme toggle is in the navbar
|
||||
const themeButton = page.locator('svg').locator('..').filter({ hasText: /path/ });
|
||||
|
||||
if (await themeButton.isVisible()) {
|
||||
// Get current theme
|
||||
const htmlElement = page.locator('html');
|
||||
const currentTheme = await htmlElement.getAttribute('data-theme');
|
||||
|
||||
await themeButton.click();
|
||||
|
||||
// Wait for theme change
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Theme should have changed
|
||||
const newTheme = await htmlElement.getAttribute('data-theme');
|
||||
expect(newTheme).not.toBe(currentTheme);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Registration (Non-Self-Hosted)', () => {
|
||||
test('should show registration link when not self-hosted', async ({ page }) => {
|
||||
await page.goto('/users/sign_in');
|
||||
|
||||
// Registration link may or may not be visible depending on SELF_HOSTED setting
|
||||
const registerLink = page.getByRole('link', { name: 'Register' }).first(); // Use first to avoid strict mode
|
||||
const selfHosted = await page.getAttribute('html', 'data-self-hosted');
|
||||
|
||||
if (selfHosted === 'false') {
|
||||
await expect(registerLink).toBeVisible();
|
||||
} else {
|
||||
await expect(registerLink).not.toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should display registration form when available', async ({ page }) => {
|
||||
await page.goto('/users/sign_up');
|
||||
|
||||
// May redirect if self-hosted, so check current URL
|
||||
if (page.url().includes('/users/sign_up')) {
|
||||
await expect(page.getByRole('heading', { name: 'Register now!' })).toBeVisible();
|
||||
await expect(page.getByLabel('Email')).toBeVisible();
|
||||
await expect(page.locator('input[id="user_password"]')).toBeVisible(); // Be specific for main password field
|
||||
await expect(page.locator('input[id="user_password_confirmation"]')).toBeVisible(); // Use ID for confirmation field
|
||||
await expect(page.getByRole('button', { name: 'Sign up' })).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile Authentication', () => {
|
||||
test('should work on mobile viewport', async ({ page }) => {
|
||||
// Set mobile viewport
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
await page.goto('/users/sign_in');
|
||||
|
||||
// Check mobile-responsive login form
|
||||
await expect(page.getByLabel('Email')).toBeVisible();
|
||||
await expect(page.getByLabel('Password')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
||||
|
||||
// Test login on mobile
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('should handle mobile navigation after login', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
// Manual login
|
||||
await page.goto('/users/sign_in');
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||
|
||||
// Open mobile navigation using hamburger menu
|
||||
const mobileMenuButton = page.locator('label[tabindex="0"]').or(
|
||||
page.locator('button').filter({ hasText: /menu/i })
|
||||
);
|
||||
|
||||
if (await mobileMenuButton.isVisible()) {
|
||||
await mobileMenuButton.click();
|
||||
|
||||
// Should see user email in mobile menu structure
|
||||
await expect(page.getByText(TEST_USERS.DEMO.email)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle mobile logout', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
// Manual login
|
||||
await page.goto('/users/sign_in');
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||
|
||||
// In mobile view, user dropdown should still work
|
||||
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||
await userDropdown.locator('summary').click();
|
||||
|
||||
// Use evaluate to trigger the logout form submission properly
|
||||
await 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 page.waitForURL('/', { timeout: 10000 });
|
||||
|
||||
// Verify user is logged out - should see login options
|
||||
await expect(page.getByRole('link', { name: 'Sign in' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Navigation Integration', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Manual login for each test in this describe block
|
||||
await page.goto('/users/sign_in');
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('should show user email in navigation', async ({ page }) => {
|
||||
// User email should be visible in the navbar dropdown
|
||||
await expect(page.getByText(TEST_USERS.DEMO.email)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show admin indicator for admin users', async ({ page }) => {
|
||||
// Look for admin star indicator if user is admin
|
||||
const adminStar = page.getByText('⭐️');
|
||||
// Admin indicator may not be visible for demo user
|
||||
const isVisible = await adminStar.isVisible();
|
||||
// Just verify the page doesn't crash
|
||||
expect(typeof isVisible).toBe('boolean');
|
||||
});
|
||||
|
||||
test('should access settings through navigation', async ({ page }) => {
|
||||
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||
await userDropdown.locator('summary').click();
|
||||
await page.getByRole('link', { name: 'Settings' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/settings/);
|
||||
await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show version badge in navigation', async ({ page }) => {
|
||||
// Version badge should be visible
|
||||
const versionBadge = page.locator('.badge').filter({ hasText: /\d+\.\d+/ });
|
||||
await expect(versionBadge).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show notifications dropdown', async ({ page }) => {
|
||||
// Notifications dropdown should be present - look for the notification bell icon more directly
|
||||
const notificationDropdown = page.locator('[data-controller="notifications"]');
|
||||
|
||||
if (await notificationDropdown.isVisible()) {
|
||||
await expect(notificationDropdown).toBeVisible();
|
||||
} else {
|
||||
// Alternative: Look for notification button/bell icon
|
||||
const notificationButton = page.locator('svg').filter({ hasText: /path.*stroke.*d=/ });
|
||||
if (await notificationButton.first().isVisible()) {
|
||||
await expect(notificationButton.first()).toBeVisible();
|
||||
} else {
|
||||
// If notifications aren't available, just check that the navbar exists
|
||||
const navbar = page.locator('.navbar');
|
||||
await expect(navbar).toBeVisible();
|
||||
console.log('Notifications dropdown not found, but navbar is present');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Session Management', () => {
|
||||
test('should maintain session across page reloads', async ({ page }) => {
|
||||
// Manual login
|
||||
await page.goto('/users/sign_in');
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||
|
||||
// Reload page
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should still be logged in
|
||||
await expect(page.getByText(TEST_USERS.DEMO.email)).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/map/);
|
||||
});
|
||||
|
||||
test('should handle session timeout gracefully', async ({ page }) => {
|
||||
// Manual login
|
||||
await page.goto('/users/sign_in');
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||
|
||||
// Clear all cookies to simulate session timeout
|
||||
await page.context().clearCookies();
|
||||
|
||||
// Try to access protected page
|
||||
await page.goto('/settings');
|
||||
|
||||
// Should redirect to login
|
||||
await expect(page).toHaveURL(/\/users\/sign_in/);
|
||||
});
|
||||
});
|
||||
});
|
||||
366
e2e/fixtures/test-helpers.ts
Normal file
366
e2e/fixtures/test-helpers.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
const adminStar = this.page.getByText('⭐️');
|
||||
return await adminStar.isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current theme from HTML data attribute
|
||||
*/
|
||||
async getCurrentTheme(): Promise<string | null> {
|
||||
return await this.page.getAttribute('html', 'data-theme');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if app is in self-hosted mode
|
||||
*/
|
||||
async isSelfHosted(): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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' }
|
||||
};
|
||||
39
e2e/global-setup.ts
Normal file
39
e2e/global-setup.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { chromium, FullConfig } from '@playwright/test';
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
const { baseURL } = config.projects[0].use;
|
||||
|
||||
// Launch browser for setup operations
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
try {
|
||||
// Wait for the server to be ready
|
||||
console.log('Checking if Dawarich server is available...');
|
||||
|
||||
// Try to connect to the health endpoint
|
||||
try {
|
||||
await page.goto(baseURL + '/api/v1/health', { waitUntil: 'networkidle', timeout: 10000 });
|
||||
console.log('Health endpoint is accessible');
|
||||
} catch (error) {
|
||||
console.log('Health endpoint not available, trying main page...');
|
||||
}
|
||||
|
||||
// Check if we can access the main app
|
||||
const response = await page.goto(baseURL + '/', { timeout: 15000 });
|
||||
if (!response?.ok()) {
|
||||
throw new Error(`Server not available. Status: ${response?.status()}. Make sure Dawarich is running on ${baseURL}`);
|
||||
}
|
||||
|
||||
console.log('Dawarich server is ready for testing');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to Dawarich server:', error);
|
||||
console.error(`Please make sure Dawarich is running on ${baseURL}`);
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
427
e2e/map.spec.ts
Normal file
427
e2e/map.spec.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { TestHelpers } from './fixtures/test-helpers';
|
||||
|
||||
test.describe('Map Functionality', () => {
|
||||
let helpers: TestHelpers;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
helpers = new TestHelpers(page);
|
||||
await helpers.loginAsDemo();
|
||||
});
|
||||
|
||||
test.describe('Main Map Interface', () => {
|
||||
test('should display map page correctly', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Check page title and basic elements
|
||||
await expect(page).toHaveTitle(/Map.*Dawarich/);
|
||||
// Check for map controls instead of specific #map element
|
||||
await expect(page.getByRole('button', { name: 'Zoom in' })).toBeVisible();
|
||||
|
||||
// Wait for map to be fully loaded
|
||||
await helpers.waitForMap();
|
||||
|
||||
// Check for time range controls
|
||||
await expect(page.getByLabel('Start at')).toBeVisible();
|
||||
await expect(page.getByLabel('End at')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Search' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should load Leaflet map correctly', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
await helpers.waitForMap();
|
||||
|
||||
// Check that map functionality is available - either Leaflet or other map implementation
|
||||
const mapInitialized = await page.evaluate(() => {
|
||||
const mapElement = document.querySelector('#map');
|
||||
return mapElement && (mapElement as any)._leaflet_id;
|
||||
});
|
||||
|
||||
// If Leaflet is not found, check for basic map functionality
|
||||
if (!mapInitialized) {
|
||||
// Verify map controls are working
|
||||
await expect(page.getByRole('button', { name: 'Zoom in' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Zoom out' })).toBeVisible();
|
||||
} else {
|
||||
expect(mapInitialized).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('should display time range controls', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Check time controls
|
||||
await expect(page.getByLabel('Start at')).toBeVisible();
|
||||
await expect(page.getByLabel('End at')).toBeVisible();
|
||||
|
||||
// Check quick time range buttons
|
||||
await expect(page.getByRole('link', { name: 'Today' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Last 7 days' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Last month' })).toBeVisible();
|
||||
|
||||
// Check navigation arrows
|
||||
await expect(page.getByRole('link', { name: '◀️' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: '▶️' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate between dates using arrows', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Wait for initial page load
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Verify navigation arrows exist and are functional
|
||||
const prevArrow = page.getByRole('link', { name: '◀️' });
|
||||
const nextArrow = page.getByRole('link', { name: '▶️' });
|
||||
|
||||
await expect(prevArrow).toBeVisible();
|
||||
await expect(nextArrow).toBeVisible();
|
||||
|
||||
// Check that arrows have proper href attributes with date parameters
|
||||
const prevHref = await prevArrow.getAttribute('href');
|
||||
const nextHref = await nextArrow.getAttribute('href');
|
||||
|
||||
expect(prevHref).toContain('start_at');
|
||||
expect(nextHref).toContain('start_at');
|
||||
});
|
||||
|
||||
test('should use quick time range buttons', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Verify quick time range buttons exist and have proper hrefs
|
||||
const todayButton = page.getByRole('link', { name: 'Today' });
|
||||
const lastWeekButton = page.getByRole('link', { name: 'Last 7 days' });
|
||||
const lastMonthButton = page.getByRole('link', { name: 'Last month' });
|
||||
|
||||
await expect(todayButton).toBeVisible();
|
||||
await expect(lastWeekButton).toBeVisible();
|
||||
await expect(lastMonthButton).toBeVisible();
|
||||
|
||||
// Check that buttons have proper href attributes with date parameters
|
||||
const todayHref = await todayButton.getAttribute('href');
|
||||
const lastWeekHref = await lastWeekButton.getAttribute('href');
|
||||
const lastMonthHref = await lastMonthButton.getAttribute('href');
|
||||
|
||||
expect(todayHref).toContain('start_at');
|
||||
expect(lastWeekHref).toContain('start_at');
|
||||
expect(lastMonthHref).toContain('start_at');
|
||||
});
|
||||
|
||||
test('should search custom date range', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Verify custom date range form exists
|
||||
const startInput = page.getByLabel('Start at');
|
||||
const endInput = page.getByLabel('End at');
|
||||
const searchButton = page.getByRole('button', { name: 'Search' });
|
||||
|
||||
await expect(startInput).toBeVisible();
|
||||
await expect(endInput).toBeVisible();
|
||||
await expect(searchButton).toBeVisible();
|
||||
|
||||
// Test that we can interact with the form
|
||||
await startInput.fill('2024-01-01T00:00');
|
||||
await endInput.fill('2024-01-02T23:59');
|
||||
|
||||
// Verify form inputs work
|
||||
await expect(startInput).toHaveValue('2024-01-01T00:00');
|
||||
await expect(endInput).toHaveValue('2024-01-02T23:59');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Map Layers and Controls', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
await helpers.waitForMap();
|
||||
});
|
||||
|
||||
test('should display layer control', async ({ page }) => {
|
||||
// Look for layer control (Leaflet control)
|
||||
const layerControl = page.locator('.leaflet-control-layers');
|
||||
await expect(layerControl).toBeVisible();
|
||||
});
|
||||
|
||||
test('should toggle layer control', async ({ page }) => {
|
||||
const layerControl = page.locator('.leaflet-control-layers');
|
||||
|
||||
if (await layerControl.isVisible()) {
|
||||
// Click to expand if collapsed
|
||||
await layerControl.click();
|
||||
|
||||
// Should show layer options
|
||||
await page.waitForTimeout(500);
|
||||
// Layer control should be expanded (check for typical layer control elements)
|
||||
const expanded = await page.locator('.leaflet-control-layers-expanded').isVisible();
|
||||
if (!expanded) {
|
||||
// Try clicking on the control toggle
|
||||
const toggle = layerControl.locator('.leaflet-control-layers-toggle');
|
||||
if (await toggle.isVisible()) {
|
||||
await toggle.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should switch between base layers', async ({ page }) => {
|
||||
// This test depends on having multiple base layers available
|
||||
// We'll check if base layer options exist and try to switch
|
||||
|
||||
const layerControl = page.locator('.leaflet-control-layers');
|
||||
await layerControl.click();
|
||||
|
||||
// Look for base layer radio buttons (OpenStreetMap, OpenTopo, etc.)
|
||||
const baseLayerRadios = page.locator('input[type="radio"][name="leaflet-base-layers"]');
|
||||
const radioCount = await baseLayerRadios.count();
|
||||
|
||||
if (radioCount > 1) {
|
||||
// Switch to different base layer
|
||||
await baseLayerRadios.nth(1).click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify the layer switched (tiles should reload)
|
||||
await expect(page.locator('.leaflet-tile-loaded')).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should toggle overlay layers', async ({ page }) => {
|
||||
const layerControl = page.locator('.leaflet-control-layers');
|
||||
await layerControl.click();
|
||||
|
||||
// Wait for the layer control to expand
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Look for overlay checkboxes (Points, Routes, Heatmap, etc.)
|
||||
const overlayCheckboxes = page.locator('.leaflet-control-layers-overlays input[type="checkbox"]');
|
||||
const checkboxCount = await overlayCheckboxes.count();
|
||||
|
||||
if (checkboxCount > 0) {
|
||||
// Toggle first overlay - check if it's visible first
|
||||
const firstCheckbox = overlayCheckboxes.first();
|
||||
|
||||
// Wait for checkbox to be visible, especially on mobile
|
||||
await expect(firstCheckbox).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const wasChecked = await firstCheckbox.isChecked();
|
||||
|
||||
// If on mobile, the checkbox might be hidden behind other elements
|
||||
// Use JavaScript click as fallback
|
||||
try {
|
||||
await firstCheckbox.click({ force: true });
|
||||
} catch (error) {
|
||||
// Fallback to JavaScript click if element is not interactable
|
||||
await page.evaluate(() => {
|
||||
const checkbox = document.querySelector('.leaflet-control-layers-overlays input[type="checkbox"]') as HTMLInputElement;
|
||||
if (checkbox) {
|
||||
checkbox.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify state changed
|
||||
const isNowChecked = await firstCheckbox.isChecked();
|
||||
expect(isNowChecked).toBe(!wasChecked);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Map Data Display', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
await helpers.waitForMap();
|
||||
});
|
||||
|
||||
test('should display distance and points statistics', async ({ page }) => {
|
||||
// Check for distance and points statistics - they appear as "0 km | 1 points"
|
||||
const statsDisplay = page.getByText(/\d+\s*km.*\d+\s*points/i);
|
||||
await expect(statsDisplay.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display map attribution', async ({ page }) => {
|
||||
// Check for Leaflet attribution
|
||||
const attribution = page.locator('.leaflet-control-attribution');
|
||||
await expect(attribution).toBeVisible();
|
||||
|
||||
// Should contain some attribution text
|
||||
const attributionText = await attribution.textContent();
|
||||
expect(attributionText).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should display map scale control', async ({ page }) => {
|
||||
// Check for scale control
|
||||
const scaleControl = page.locator('.leaflet-control-scale');
|
||||
await expect(scaleControl).toBeVisible();
|
||||
});
|
||||
|
||||
test('should zoom in and out', async ({ page }) => {
|
||||
// Find zoom controls
|
||||
const zoomIn = page.locator('.leaflet-control-zoom-in');
|
||||
const zoomOut = page.locator('.leaflet-control-zoom-out');
|
||||
|
||||
await expect(zoomIn).toBeVisible();
|
||||
await expect(zoomOut).toBeVisible();
|
||||
|
||||
// Test zoom in
|
||||
await zoomIn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Test zoom out
|
||||
await zoomOut.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Map should still be visible and functional
|
||||
await expect(page.locator('#map')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle map dragging', async ({ page }) => {
|
||||
// Get map container
|
||||
const mapContainer = page.locator('#map .leaflet-container');
|
||||
await expect(mapContainer).toBeVisible();
|
||||
|
||||
// Get initial map center (if available)
|
||||
const initialBounds = await page.evaluate(() => {
|
||||
const mapElement = document.querySelector('#map');
|
||||
if (mapElement && (mapElement as any)._leaflet_id) {
|
||||
const map = (window as any).L.map((mapElement as any)._leaflet_id);
|
||||
return map.getBounds();
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Simulate drag
|
||||
await mapContainer.hover();
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(100, 100);
|
||||
await page.mouse.up();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Map should still be functional
|
||||
await expect(mapContainer).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Points Interaction', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
await helpers.waitForMap();
|
||||
});
|
||||
|
||||
test('should click on points to show details', async ({ page }) => {
|
||||
// Look for point markers on the map
|
||||
const pointMarkers = page.locator('.leaflet-marker-icon, .leaflet-interactive[fill]');
|
||||
const markerCount = await pointMarkers.count();
|
||||
|
||||
if (markerCount > 0) {
|
||||
// Click on first point
|
||||
await pointMarkers.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should show popup with point details
|
||||
const popup = page.locator('.leaflet-popup, .popup');
|
||||
await expect(popup).toBeVisible();
|
||||
|
||||
// Popup should contain some data
|
||||
const popupContent = await popup.textContent();
|
||||
expect(popupContent).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('should show point deletion option in popup', async ({ page }) => {
|
||||
// This test assumes there are points to click on
|
||||
const pointMarkers = page.locator('.leaflet-marker-icon, .leaflet-interactive[fill]');
|
||||
const markerCount = await pointMarkers.count();
|
||||
|
||||
if (markerCount > 0) {
|
||||
await pointMarkers.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Look for delete option in popup
|
||||
const deleteLink = page.getByRole('link', { name: /delete/i });
|
||||
if (await deleteLink.isVisible()) {
|
||||
await expect(deleteLink).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile Map Experience', () => {
|
||||
test('should work on mobile viewport', async ({ page }) => {
|
||||
// Set mobile viewport
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
await helpers.navigateTo('Map');
|
||||
await helpers.waitForMap();
|
||||
|
||||
// Map should be visible and functional on mobile
|
||||
await expect(page.locator('#map')).toBeVisible();
|
||||
|
||||
// Time controls should be responsive
|
||||
await expect(page.getByLabel('Start at')).toBeVisible();
|
||||
await expect(page.getByLabel('End at')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle mobile touch interactions', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await helpers.navigateTo('Map');
|
||||
await helpers.waitForMap();
|
||||
|
||||
const mapContainer = page.locator('#map');
|
||||
|
||||
// Simulate touch interactions using click (more compatible than tap)
|
||||
await mapContainer.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Map should remain functional
|
||||
await expect(mapContainer).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display mobile-optimized controls', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Check that controls stack properly on mobile
|
||||
const timeControls = page.locator('.flex').filter({ hasText: /Start at|End at/ });
|
||||
await expect(timeControls.first()).toBeVisible();
|
||||
|
||||
// Quick action buttons should be visible
|
||||
await expect(page.getByRole('link', { name: 'Today' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Map Performance', () => {
|
||||
test('should load map within reasonable time', async ({ page }) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
await helpers.navigateTo('Map');
|
||||
await helpers.waitForMap();
|
||||
|
||||
const loadTime = Date.now() - startTime;
|
||||
|
||||
// Check if we're on mobile and adjust timeout accordingly
|
||||
const isMobile = await helpers.isMobileViewport();
|
||||
const maxLoadTime = isMobile ? 25000 : 15000; // 25s for mobile, 15s for desktop
|
||||
|
||||
expect(loadTime).toBeLessThan(maxLoadTime);
|
||||
});
|
||||
|
||||
test('should handle large datasets efficiently', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Set a longer date range that might have more data
|
||||
await page.getByLabel('Start at').fill('2024-01-01T00:00');
|
||||
await page.getByLabel('End at').fill('2024-12-31T23:59');
|
||||
await page.getByRole('button', { name: 'Search' }).click();
|
||||
|
||||
// Should load without timing out
|
||||
await page.waitForLoadState('networkidle', { timeout: 30000 });
|
||||
await helpers.waitForMap();
|
||||
|
||||
// Map should still be interactive
|
||||
const zoomIn = page.locator('.leaflet-control-zoom-in');
|
||||
await zoomIn.click();
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
472
e2e/navigation.spec.ts
Normal file
472
e2e/navigation.spec.ts
Normal file
@@ -0,0 +1,472 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { TestHelpers } from './fixtures/test-helpers';
|
||||
|
||||
test.describe('Navigation', () => {
|
||||
let helpers: TestHelpers;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
helpers = new TestHelpers(page);
|
||||
await helpers.loginAsDemo();
|
||||
});
|
||||
|
||||
test.describe('Main Navigation', () => {
|
||||
test('should display main navigation elements', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Check for main navigation items - note Trips has α symbol, Settings is in user dropdown
|
||||
await expect(page.getByRole('link', { name: 'Map', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /Trips/ })).toBeVisible(); // Match with α symbol
|
||||
await expect(page.getByRole('link', { name: 'Stats' })).toBeVisible();
|
||||
|
||||
// Settings is in user dropdown, not main nav - check user dropdown instead
|
||||
const userDropdown = page.locator('details').filter({ hasText: /@/ }).first();
|
||||
await expect(userDropdown).toBeVisible();
|
||||
|
||||
// Check for "My data" dropdown - select the visible one (not hidden mobile version)
|
||||
await expect(page.getByText('My data').and(page.locator(':visible'))).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to Map section', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
await expect(page).toHaveURL(/\/map/);
|
||||
// No h1 heading on map page - check for map interface instead
|
||||
await expect(page.locator('#map')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to Trips section', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
await expect(page).toHaveURL(/\/trips/);
|
||||
// No h1 heading on trips page - check for trips interface instead (visible elements only)
|
||||
await expect(page.getByText(/trip|distance|duration/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to Stats section', async ({ page }) => {
|
||||
await helpers.navigateTo('Stats');
|
||||
|
||||
await expect(page).toHaveURL(/\/stats/);
|
||||
// No h1 heading on stats page - check for stats interface instead (visible elements only)
|
||||
await expect(page.getByText(/total.*distance|points.*tracked/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to Settings section', async ({ page }) => {
|
||||
await helpers.navigateTo('Settings');
|
||||
|
||||
await expect(page).toHaveURL(/\/settings/);
|
||||
// No h1 heading on settings page - check for settings interface instead
|
||||
await expect(page.getByText(/integration|map.*configuration/i).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('My Data Dropdown', () => {
|
||||
test('should expand My data dropdown', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Click on "My data" dropdown - select the visible one (not hidden mobile version)
|
||||
await page.getByText('My data').and(page.locator(':visible')).click();
|
||||
|
||||
// Should show dropdown items
|
||||
await expect(page.getByRole('link', { name: 'Points' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Visits' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Imports' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Exports' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to Points', async ({ page }) => {
|
||||
await helpers.navigateTo('Points');
|
||||
|
||||
await expect(page).toHaveURL(/\/points/);
|
||||
// No h1 heading on points page - check for points interface instead (visible elements only)
|
||||
await expect(page.getByText(/point|location|coordinate/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to Visits', async ({ page }) => {
|
||||
await helpers.navigateTo('Visits');
|
||||
|
||||
await expect(page).toHaveURL(/\/visits/);
|
||||
// No h1 heading on visits page - check for visits interface instead (visible elements only)
|
||||
await expect(page.getByText(/visit|place|duration/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to Imports', async ({ page }) => {
|
||||
await helpers.navigateTo('Imports');
|
||||
|
||||
await expect(page).toHaveURL(/\/imports/);
|
||||
// No h1 heading on imports page - check for imports interface instead (visible elements only)
|
||||
await expect(page.getByText(/import|file|source/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to Exports', async ({ page }) => {
|
||||
await helpers.navigateTo('Exports');
|
||||
|
||||
await expect(page).toHaveURL(/\/exports/);
|
||||
// No h1 heading on exports page - check for exports interface instead (visible elements only)
|
||||
await expect(page.getByText(/export|download|format/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('User Navigation', () => {
|
||||
test('should display user menu', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Click on user dropdown using the details/summary structure
|
||||
const userDropdown = page.locator('details').filter({ hasText: /@/ }).first();
|
||||
await userDropdown.locator('summary').click();
|
||||
|
||||
// Should show user menu items
|
||||
await expect(page.getByRole('link', { name: 'Account' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Logout' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to Account settings', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
const userDropdown = page.locator('details').filter({ hasText: /@/ }).first();
|
||||
await userDropdown.locator('summary').click();
|
||||
await page.getByRole('link', { name: 'Account' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/users\/edit/);
|
||||
await expect(page.getByLabel('Email')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show logout functionality', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
const userDropdown = page.locator('details').filter({ hasText: /@/ }).first();
|
||||
await userDropdown.locator('summary').click();
|
||||
await page.getByRole('link', { name: 'Logout' }).click();
|
||||
|
||||
// Should redirect to home/login
|
||||
await expect(page).toHaveURL('/');
|
||||
await expect(page.getByRole('link', { name: 'Sign in' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Breadcrumb Navigation', () => {
|
||||
test('should show breadcrumbs on detail pages', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
// Look for trip links
|
||||
const tripLinks = page.getByRole('link').filter({ hasText: /trip|km|miles/i });
|
||||
const linkCount = await tripLinks.count();
|
||||
|
||||
if (linkCount > 0) {
|
||||
// Click on first trip
|
||||
await tripLinks.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should show breadcrumb navigation
|
||||
const breadcrumbs = page.locator('.breadcrumb, .breadcrumbs, nav').filter({ hasText: /trip/i });
|
||||
if (await breadcrumbs.isVisible()) {
|
||||
await expect(breadcrumbs).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should navigate back using breadcrumbs', async ({ page }) => {
|
||||
await helpers.navigateTo('Imports');
|
||||
|
||||
// Look for import detail links
|
||||
const importLinks = page.getByRole('link').filter({ hasText: /\.json|\.gpx|\.rec/i });
|
||||
const linkCount = await importLinks.count();
|
||||
|
||||
if (linkCount > 0) {
|
||||
await importLinks.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Look for back navigation
|
||||
const backLink = page.getByRole('link', { name: /back|imports/i });
|
||||
if (await backLink.isVisible()) {
|
||||
await backLink.click();
|
||||
await expect(page).toHaveURL(/\/imports/);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('URL Navigation', () => {
|
||||
test('should handle direct URL navigation', async ({ page }) => {
|
||||
// Navigate directly to different sections - no h1 headings on pages
|
||||
await page.goto('/map');
|
||||
await expect(page.locator('#map')).toBeVisible();
|
||||
|
||||
await page.goto('/trips');
|
||||
await expect(page.getByText(/trip|distance|duration/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||
|
||||
await page.goto('/stats');
|
||||
await expect(page.getByText(/total.*distance|points.*tracked/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||
|
||||
await page.goto('/settings');
|
||||
await expect(page.getByText(/integration|map.*configuration/i).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle browser back/forward navigation', async ({ page }) => {
|
||||
// Navigate to different pages
|
||||
await helpers.navigateTo('Map');
|
||||
await helpers.navigateTo('Trips');
|
||||
await helpers.navigateTo('Stats');
|
||||
|
||||
// Use browser back
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/trips/);
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/map/);
|
||||
|
||||
// Use browser forward
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(/\/trips/);
|
||||
});
|
||||
|
||||
test('should handle URL parameters', async ({ page }) => {
|
||||
// Navigate to map with date parameters
|
||||
await page.goto('/map?start_at=2024-01-01T00:00&end_at=2024-01-02T23:59');
|
||||
|
||||
// Should preserve URL parameters
|
||||
await expect(page).toHaveURL(/start_at=2024-01-01/);
|
||||
await expect(page).toHaveURL(/end_at=2024-01-02/);
|
||||
|
||||
// Form should be populated with URL parameters - use display labels
|
||||
await expect(page.getByLabel('Start at')).toHaveValue(/2024-01-01/);
|
||||
await expect(page.getByLabel('End at')).toHaveValue(/2024-01-02/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile Navigation', () => {
|
||||
test('should show mobile navigation menu', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Look for mobile menu button (hamburger)
|
||||
const mobileMenuButton = page.locator('button').filter({ hasText: /menu|☰|≡/ }).first();
|
||||
|
||||
if (await mobileMenuButton.isVisible()) {
|
||||
await mobileMenuButton.click();
|
||||
|
||||
// Should show mobile navigation
|
||||
await expect(page.getByRole('link', { name: 'Map' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Trips' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Stats' })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle mobile navigation interactions', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Open mobile navigation
|
||||
await helpers.openMobileNavigation();
|
||||
|
||||
// Navigate to different section
|
||||
await page.getByRole('link', { name: 'Stats' }).click();
|
||||
|
||||
// Should navigate successfully - no h1 heading on stats page
|
||||
await expect(page).toHaveURL(/\/stats/);
|
||||
await expect(page.getByText(/total.*distance|points.*tracked/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle mobile dropdown menus', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Open mobile navigation
|
||||
await helpers.openMobileNavigation();
|
||||
|
||||
// Look for "My data" in mobile menu - select the visible one
|
||||
const myDataMobile = page.getByText('My data').and(page.locator(':visible'));
|
||||
if (await myDataMobile.isVisible()) {
|
||||
await myDataMobile.click();
|
||||
|
||||
// Should show mobile dropdown
|
||||
await expect(page.getByRole('link', { name: 'Points' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Imports' })).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Active Navigation State', () => {
|
||||
test('should highlight active navigation item', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Map should be active - use exact match to avoid attribution links
|
||||
const mapLink = page.getByRole('link', { name: 'Map', exact: true });
|
||||
await expect(mapLink).toHaveClass(/active|current/);
|
||||
|
||||
// Navigate to different section
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
// Trips should now be active
|
||||
const tripsLink = page.getByRole('link', { name: 'Trips' });
|
||||
await expect(tripsLink).toHaveClass(/active|current/);
|
||||
});
|
||||
|
||||
test('should update active state on URL change', async ({ page }) => {
|
||||
// Navigate directly via URL
|
||||
await page.goto('/stats');
|
||||
|
||||
// Stats should be active - use exact match to avoid "Update stats" button
|
||||
const statsLink = page.getByRole('link', { name: 'Stats', exact: true });
|
||||
await expect(statsLink).toHaveClass(/active|current/);
|
||||
|
||||
// Navigate via URL again
|
||||
await page.goto('/settings');
|
||||
|
||||
// Settings link is in user dropdown, not main nav - check URL instead
|
||||
await expect(page).toHaveURL(/\/settings/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Navigation Performance', () => {
|
||||
test('should navigate between sections quickly', async ({ page }) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Navigate through multiple sections (Settings uses different navigation)
|
||||
await helpers.navigateTo('Map');
|
||||
await helpers.navigateTo('Trips');
|
||||
await helpers.navigateTo('Stats');
|
||||
await helpers.navigateTo('Points'); // Navigate to Points instead of Settings
|
||||
|
||||
const endTime = Date.now();
|
||||
const totalTime = endTime - startTime;
|
||||
|
||||
// Should complete navigation within reasonable time
|
||||
expect(totalTime).toBeLessThan(10000); // 10 seconds
|
||||
});
|
||||
|
||||
test('should handle rapid navigation clicks', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Rapidly click different navigation items (Settings is not in main nav)
|
||||
await page.getByRole('link', { name: /Trips/ }).click(); // Match with α symbol
|
||||
await page.getByRole('link', { name: 'Stats' }).click();
|
||||
await page.getByRole('link', { name: 'Map', exact: true }).click();
|
||||
|
||||
// Should end up on the last clicked item
|
||||
await expect(page).toHaveURL(/\/map/);
|
||||
await expect(page.locator('#map')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Error Handling', () => {
|
||||
test('should handle non-existent routes', async ({ page }) => {
|
||||
// Navigate to a non-existent route
|
||||
await page.goto('/non-existent-page');
|
||||
|
||||
// Should show 404 or redirect to valid page
|
||||
const currentUrl = page.url();
|
||||
|
||||
// Either shows 404 page or redirects to valid page
|
||||
if (currentUrl.includes('non-existent-page')) {
|
||||
// Should show 404 page
|
||||
await expect(page.getByText(/404|not found/i)).toBeVisible();
|
||||
} else {
|
||||
// Should redirect to valid page
|
||||
expect(currentUrl).toMatch(/\/(map|home|$)/);
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle network errors gracefully', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Mock network error for navigation
|
||||
await page.route('**/trips', route => route.abort());
|
||||
|
||||
// Try to navigate
|
||||
await page.getByRole('link', { name: 'Trips' }).click();
|
||||
|
||||
// Should handle gracefully (stay on current page or show error)
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should not crash - page should still be functional - use exact match
|
||||
await expect(page.getByRole('link', { name: 'Map', exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Keyboard Navigation', () => {
|
||||
test('should support keyboard navigation', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Press Tab to navigate to links
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
// Should focus on navigation elements
|
||||
const focusedElement = page.locator(':focus');
|
||||
await expect(focusedElement).toBeVisible();
|
||||
|
||||
// Should be able to navigate with keyboard
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should navigate to focused element - use exact match to avoid attribution links
|
||||
await expect(page.getByRole('link', { name: 'Map', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle keyboard shortcuts', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Test common keyboard shortcuts if they exist
|
||||
// This depends on the application implementing keyboard shortcuts
|
||||
|
||||
// For example, if there's a keyboard shortcut for settings
|
||||
await page.keyboard.press('Alt+S');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// May or may not navigate (depends on implementation)
|
||||
const currentUrl = page.url();
|
||||
|
||||
// Just verify the page is still functional - use exact match
|
||||
await expect(page.getByRole('link', { name: 'Map', exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Accessibility', () => {
|
||||
test('should have proper ARIA labels', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Check for main navigation landmark
|
||||
const mainNav = page.locator('nav[role="navigation"]').or(page.locator('nav'));
|
||||
await expect(mainNav.first()).toBeVisible();
|
||||
|
||||
// Check for accessible navigation items
|
||||
const navItems = page.getByRole('link');
|
||||
const navCount = await navItems.count();
|
||||
|
||||
expect(navCount).toBeGreaterThan(0);
|
||||
|
||||
// Navigation items should have proper text content
|
||||
for (let i = 0; i < Math.min(navCount, 5); i++) {
|
||||
const navItem = navItems.nth(i);
|
||||
const text = await navItem.textContent();
|
||||
expect(text).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('should support screen reader navigation', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// No h1 headings exist - check for navigation landmark instead
|
||||
const nav = page.locator('nav').first();
|
||||
await expect(nav).toBeVisible();
|
||||
|
||||
// Check for proper link labels
|
||||
const links = page.getByRole('link');
|
||||
const linkCount = await links.count();
|
||||
|
||||
// Most links should have text content (skip icon-only links)
|
||||
let linksWithText = 0;
|
||||
for (let i = 0; i < Math.min(linkCount, 10); i++) {
|
||||
const link = links.nth(i);
|
||||
const text = await link.textContent();
|
||||
if (text?.trim()) {
|
||||
linksWithText++;
|
||||
}
|
||||
}
|
||||
// At least half of the links should have text content
|
||||
expect(linksWithText).toBeGreaterThan(Math.min(linkCount, 10) / 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
418
e2e/trips.spec.ts
Normal file
418
e2e/trips.spec.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { TestHelpers } from './fixtures/test-helpers';
|
||||
|
||||
test.describe('Trips', () => {
|
||||
let helpers: TestHelpers;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
helpers = new TestHelpers(page);
|
||||
await helpers.loginAsDemo();
|
||||
});
|
||||
|
||||
test.describe('Trips List', () => {
|
||||
test('should display trips page correctly', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
// Check page title and elements
|
||||
await expect(page).toHaveTitle(/Trips.*Dawarich/);
|
||||
await expect(page.getByRole('heading', { name: 'Trips' })).toBeVisible();
|
||||
|
||||
// Should show "New trip" button
|
||||
await expect(page.getByRole('link', { name: 'New trip' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show trips list or empty state', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
// Check for either trips grid or empty state
|
||||
const tripsGrid = page.locator('.grid');
|
||||
const emptyState = page.getByText('Hello there!');
|
||||
|
||||
if (await tripsGrid.isVisible()) {
|
||||
await expect(tripsGrid).toBeVisible();
|
||||
} else {
|
||||
// Should show empty state with create link
|
||||
await expect(emptyState).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'create one' })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should display trip statistics if trips exist', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
// Look for trip cards
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
// Should show distance info in first trip card
|
||||
const firstCard = tripCards.first();
|
||||
await expect(firstCard.getByText(/\d+\s*(km|miles)/)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should navigate to new trip page', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
// Click "New trip" button
|
||||
await page.getByRole('link', { name: 'New trip' }).click();
|
||||
|
||||
// Should navigate to new trip page
|
||||
await expect(page).toHaveURL(/\/trips\/new/);
|
||||
await expect(page.getByRole('heading', { name: 'New trip' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Trip Creation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
await page.getByRole('link', { name: 'New trip' }).click();
|
||||
});
|
||||
|
||||
test('should show trip creation form', async ({ page }) => {
|
||||
// Should have form fields
|
||||
await expect(page.getByLabel('Name')).toBeVisible();
|
||||
await expect(page.getByLabel('Started at')).toBeVisible();
|
||||
await expect(page.getByLabel('Ended at')).toBeVisible();
|
||||
|
||||
// Should have submit button
|
||||
await expect(page.getByRole('button', { name: 'Create trip' })).toBeVisible();
|
||||
|
||||
// Should have map container
|
||||
await expect(page.locator('#map')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create trip with valid data', async ({ page }) => {
|
||||
// Fill form fields
|
||||
await page.getByLabel('Name').fill('Test Trip');
|
||||
await page.getByLabel('Started at').fill('2024-01-01T10:00');
|
||||
await page.getByLabel('Ended at').fill('2024-01-01T18:00');
|
||||
|
||||
// Submit form
|
||||
await page.getByRole('button', { name: 'Create trip' }).click();
|
||||
|
||||
// Should redirect to trip show page
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page).toHaveURL(/\/trips\/\d+/);
|
||||
});
|
||||
|
||||
test('should validate required fields', async ({ page }) => {
|
||||
// Try to submit empty form
|
||||
await page.getByRole('button', { name: 'Create trip' }).click();
|
||||
|
||||
// Should show validation errors
|
||||
await expect(page.getByText(/can't be blank|is required/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should validate date range', async ({ page }) => {
|
||||
// Fill with invalid date range (end before start)
|
||||
await page.getByLabel('Name').fill('Invalid Trip');
|
||||
await page.getByLabel('Started at').fill('2024-01-02T10:00');
|
||||
await page.getByLabel('Ended at').fill('2024-01-01T18:00');
|
||||
|
||||
// Submit form
|
||||
await page.getByRole('button', { name: 'Create trip' }).click();
|
||||
|
||||
// Should show validation error (if backend validates this)
|
||||
await page.waitForLoadState('networkidle');
|
||||
// Note: This test assumes backend validation exists
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Trip Details', () => {
|
||||
test('should display trip details when clicked', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
// Look for trip cards
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
// Click on first trip card
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should show trip name as heading
|
||||
await expect(page.locator('h1, h2, h3').first()).toBeVisible();
|
||||
|
||||
// Should show distance info
|
||||
const distanceText = page.getByText(/\d+\s*(km|miles)/);
|
||||
if (await distanceText.count() > 0) {
|
||||
await expect(distanceText.first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should show trip map', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should show map container
|
||||
const mapContainer = page.locator('#map');
|
||||
if (await mapContainer.isVisible()) {
|
||||
await expect(mapContainer).toBeVisible();
|
||||
await helpers.waitForMap();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should show trip timeline info', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should show date/time information
|
||||
const dateInfo = page.getByText(/\d{1,2}\s+(January|February|March|April|May|June|July|August|September|October|November|December)/);
|
||||
if (await dateInfo.count() > 0) {
|
||||
await expect(dateInfo.first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should allow trip editing', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Look for edit link/button
|
||||
const editLink = page.getByRole('link', { name: /edit/i });
|
||||
if (await editLink.isVisible()) {
|
||||
await editLink.click();
|
||||
|
||||
// Should show edit form
|
||||
await expect(page.getByLabel('Name')).toBeVisible();
|
||||
await expect(page.getByLabel('Started at')).toBeVisible();
|
||||
await expect(page.getByLabel('Ended at')).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Trip Visualization', () => {
|
||||
test('should show trip on map', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Check if map is present
|
||||
const mapContainer = page.locator('#map');
|
||||
if (await mapContainer.isVisible()) {
|
||||
await helpers.waitForMap();
|
||||
|
||||
// Should have map controls
|
||||
await expect(page.getByRole('button', { name: 'Zoom in' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Zoom out' })).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should display trip route', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const mapContainer = page.locator('#map');
|
||||
if (await mapContainer.isVisible()) {
|
||||
await helpers.waitForMap();
|
||||
|
||||
// Look for route polylines
|
||||
const routeElements = page.locator('.leaflet-interactive[stroke]');
|
||||
if (await routeElements.count() > 0) {
|
||||
await expect(routeElements.first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should show trip points', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const mapContainer = page.locator('#map');
|
||||
if (await mapContainer.isVisible()) {
|
||||
await helpers.waitForMap();
|
||||
|
||||
// Look for point markers
|
||||
const pointMarkers = page.locator('.leaflet-marker-icon');
|
||||
if (await pointMarkers.count() > 0) {
|
||||
await expect(pointMarkers.first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should allow map interaction', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const mapContainer = page.locator('#map');
|
||||
if (await mapContainer.isVisible()) {
|
||||
await helpers.waitForMap();
|
||||
|
||||
// Test zoom controls
|
||||
const zoomIn = page.getByRole('button', { name: 'Zoom in' });
|
||||
const zoomOut = page.getByRole('button', { name: 'Zoom out' });
|
||||
|
||||
await zoomIn.click();
|
||||
await page.waitForTimeout(500);
|
||||
await zoomOut.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Map should still be functional
|
||||
await expect(mapContainer).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Trip Management', () => {
|
||||
test('should show trip actions', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Look for edit/delete/export options
|
||||
const editLink = page.getByRole('link', { name: /edit/i });
|
||||
const deleteButton = page.getByRole('button', { name: /delete/i }).or(page.getByRole('link', { name: /delete/i }));
|
||||
|
||||
// At least edit should be available
|
||||
if (await editLink.isVisible()) {
|
||||
await expect(editLink).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile Trips Experience', () => {
|
||||
test('should work on mobile viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
// Page should load correctly on mobile
|
||||
await expect(page.getByRole('heading', { name: 'Trips' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'New trip' })).toBeVisible();
|
||||
|
||||
// Grid should adapt to mobile
|
||||
const tripsGrid = page.locator('.grid');
|
||||
if (await tripsGrid.isVisible()) {
|
||||
await expect(tripsGrid).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle mobile trip details', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should show trip info on mobile
|
||||
await expect(page.locator('h1, h2, h3').first()).toBeVisible();
|
||||
|
||||
// Map should be responsive if present
|
||||
const mapContainer = page.locator('#map');
|
||||
if (await mapContainer.isVisible()) {
|
||||
await expect(mapContainer).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle mobile map interactions', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const mapContainer = page.locator('#map');
|
||||
if (await mapContainer.isVisible()) {
|
||||
await helpers.waitForMap();
|
||||
|
||||
// Test touch interaction
|
||||
await mapContainer.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Map should remain functional
|
||||
await expect(mapContainer).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Trip Performance', () => {
|
||||
test('should load trips page within reasonable time', async ({ page }) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const loadTime = Date.now() - startTime;
|
||||
const maxLoadTime = await helpers.isMobileViewport() ? 15000 : 10000;
|
||||
|
||||
expect(loadTime).toBeLessThan(maxLoadTime);
|
||||
});
|
||||
|
||||
test('should handle large numbers of trips', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
// Page should load without timing out
|
||||
await page.waitForLoadState('networkidle', { timeout: 30000 });
|
||||
|
||||
// Should show either trips or empty state
|
||||
const tripsGrid = page.locator('.grid');
|
||||
const emptyState = page.getByText('Hello there!');
|
||||
|
||||
expect(await tripsGrid.isVisible() || await emptyState.isVisible()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
69
playwright.config.ts
Normal file
69
playwright.config.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
|
||||
/* Take screenshot on failure */
|
||||
screenshot: 'only-on-failure',
|
||||
|
||||
/* Record video on failure */
|
||||
video: 'retain-on-failure',
|
||||
|
||||
/* Set timeout for actions */
|
||||
actionTimeout: 10000,
|
||||
|
||||
/* Set timeout for page navigation */
|
||||
navigationTimeout: 30000,
|
||||
},
|
||||
|
||||
/* Global setup for checking server availability */
|
||||
globalSetup: require.resolve('./e2e/global-setup.ts'),
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Safari',
|
||||
use: { ...devices['iPhone 12'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
27
playwright.yml
Normal file
27
playwright.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Playwright Tests
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
- name: Run Playwright tests
|
||||
run: npx playwright test
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
Reference in New Issue
Block a user