mirror of
https://github.com/spliit-app/spliit.git
synced 2026-02-13 11:06:13 +01:00
Compare commits
8 Commits
1.19.1
...
add-e2e-te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d58ff9946 | ||
|
|
e349a50600 | ||
|
|
5e81dd9deb | ||
|
|
1a4c5ee3e1 | ||
|
|
05a25f7e4f | ||
|
|
378a369c8f | ||
|
|
a042ba0ce6 | ||
|
|
9d375bb6be |
7
.claude/commands/commit.md
Normal file
7
.claude/commands/commit.md
Normal file
@@ -0,0 +1,7 @@
|
||||
Commit the changes to the repository:
|
||||
|
||||
1. Change the current directory to the root of the repository.
|
||||
2. If currently on the main branch, create a new feature branch.
|
||||
3. Add all the changes to the staging area, and make a commit.
|
||||
4. Push the changes to the remote repository.
|
||||
5. Create a pull request to the main branch, or update the existing one.
|
||||
8
.claude/commands/main.md
Normal file
8
.claude/commands/main.md
Normal file
@@ -0,0 +1,8 @@
|
||||
Go back to the main branch and pull the latest changes.
|
||||
|
||||
1. Check if there are any changes on the current branch that are not committed.
|
||||
2. If so, ask the user if they want to commit the changes.
|
||||
3. If they don't, stash the changes.
|
||||
4. Go on the main branch and pull the latest changes.
|
||||
5. Pop the changes from the stash.
|
||||
6. If there are any conflicts, resolve them.
|
||||
18
.claude/settings.json
Normal file
18
.claude/settings.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(ls:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(git:*)",
|
||||
"Bash(gh:*)",
|
||||
"Bash(cd:*)",
|
||||
"Bash(yarn:*)",
|
||||
"Bash(npm:*)",
|
||||
"WebFetch(domain:docs.anthropic.com)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -25,6 +25,10 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# crush
|
||||
.crush/
|
||||
test-results/
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
*.env
|
||||
|
||||
30
.vscode/settings.json
vendored
Normal file
30
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"workbench.colorCustomizations": {
|
||||
"statusBarItem.warningBackground": "#681DD7",
|
||||
"statusBarItem.warningForeground": "#ffffff",
|
||||
"statusBarItem.warningHoverBackground": "#681DD7",
|
||||
"statusBarItem.warningHoverForeground": "#ffffff90",
|
||||
"statusBarItem.remoteBackground": "#681DD7",
|
||||
"statusBarItem.remoteForeground": "#ffffff",
|
||||
"statusBarItem.remoteHoverBackground": "#681DD7",
|
||||
"statusBarItem.remoteHoverForeground": "#ffffff90",
|
||||
"focusBorder": "#681DD799",
|
||||
"progressBar.background": "#681DD7",
|
||||
"textLink.foreground": "#a85dff",
|
||||
"textLink.activeForeground": "#b56aff",
|
||||
"selection.background": "#5b10ca",
|
||||
"activityBarBadge.background": "#681DD7",
|
||||
"activityBarBadge.foreground": "#ffffff",
|
||||
"activityBar.activeBorder": "#681DD7",
|
||||
"list.highlightForeground": "#681dd7",
|
||||
"list.focusAndSelectionOutline": "#681DD799",
|
||||
"button.background": "#681DD7",
|
||||
"button.foreground": "#ffffff",
|
||||
"button.hoverBackground": "#752ae4",
|
||||
"tab.activeBorderTop": "#752ae4",
|
||||
"pickerGroup.foreground": "#752ae4",
|
||||
"list.activeSelectionBackground": "#681DD74d",
|
||||
"panelTitle.activeBorder": "#752ae4"
|
||||
},
|
||||
"window.title": "spliit--add-e2e-tests"
|
||||
}
|
||||
44
CLAUDE.md
Normal file
44
CLAUDE.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Build, Lint, and Test Commands
|
||||
- **Build the project**: `npm run build`
|
||||
- **Start the project**: `npm run start`
|
||||
- **Run the project in development**: `npm run dev`
|
||||
- **Lint the project**: `npm run lint`
|
||||
- **Check types**: `npm run check-types`
|
||||
- **Format code**: `npm run prettier`
|
||||
- **Test the project**: `npm run test`
|
||||
- **Run a single test**: Use Jest's `-t` option, e.g., `npm run test -- -t 'test name'`
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Import Conventions
|
||||
- Use `import` statements for importing modules.
|
||||
- Organize imports using **prettier-plugin-organize-imports**.
|
||||
- Import globals from libraries before local modules.
|
||||
|
||||
### Formatting
|
||||
- Use **Prettier** for code formatting.
|
||||
- Adhere to a line width of 80 characters where possible.
|
||||
- Use 2 spaces for indentation.
|
||||
|
||||
### Types
|
||||
- Utilize TypeScript for static typing throughout the codebase.
|
||||
- Define interfaces and types for complex objects.
|
||||
|
||||
### Naming Conventions
|
||||
- Use camelCase for variable and function names.
|
||||
- Use PascalCase for component and type/interface names.
|
||||
|
||||
### Error Handling
|
||||
- Use `try...catch` blocks for async functions.
|
||||
- Handle errors gracefully and log them where required.
|
||||
|
||||
### Miscellaneous
|
||||
- Ensure all new components are functional components.
|
||||
- Prefer arrow functions for component definition.
|
||||
- Use hooks like `useEffect` and `useState` for managing component state.
|
||||
|
||||
---
|
||||
|
||||
**Note**: Please follow these guidelines to maintain consistency and quality within the codebase.
|
||||
@@ -30,7 +30,7 @@ const nextConfig = {
|
||||
// Required to run in a codespace (see https://github.com/vercel/next.js/issues/58019)
|
||||
experimental: {
|
||||
serverActions: {
|
||||
allowedOrigins: ['localhost:3000'],
|
||||
allowedOrigins: ['localhost:3000', 'localhost:3003'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
64
package-lock.json
generated
64
package-lock.json
generated
@@ -65,6 +65,7 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.54.1",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.4.8",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
@@ -5508,6 +5509,22 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.54.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz",
|
||||
"integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.54.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "5.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.9.1.tgz",
|
||||
@@ -15528,6 +15545,53 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.54.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz",
|
||||
"integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.54.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.54.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz",
|
||||
"integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"postinstall": "prisma migrate deploy && prisma generate",
|
||||
"build-image": "./scripts/build-image.sh",
|
||||
"start-container": "docker compose --env-file container.env up",
|
||||
"test": "jest"
|
||||
"test": "jest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "npm run test:e2e -- --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "^0.5.4",
|
||||
@@ -72,6 +74,7 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.54.1",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.4.8",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
|
||||
38
playwright.config.ts
Normal file
38
playwright.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { PlaywrightTestConfig } from '@playwright/test';
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
testDir: './tests',
|
||||
timeout: 30000,
|
||||
retries: 2, // Increased retries for better stability
|
||||
use: {
|
||||
headless: true,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
ignoreHTTPSErrors: true,
|
||||
video: 'on-first-retry',
|
||||
// Add navigation timeout for more reliable page loads
|
||||
navigationTimeout: 15000,
|
||||
// Add action timeout for more reliable interactions
|
||||
actionTimeout: 10000,
|
||||
},
|
||||
// Global test settings
|
||||
expect: {
|
||||
// Default timeout for expect() assertions
|
||||
timeout: 10000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { browserName: 'chromium' },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { browserName: 'firefox' },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { browserName: 'webkit' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
305
prds/add-e2e-tests.md
Normal file
305
prds/add-e2e-tests.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# End-to-End Testing Plan for Spliit
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines a comprehensive E2E testing strategy for Spliit, a expense splitting application. The plan prioritizes testing the most critical user workflows while ensuring good coverage of core features.
|
||||
|
||||
## Application Features Analysis
|
||||
|
||||
Based on the codebase analysis, Spliit is a group expense management application with the following key features:
|
||||
|
||||
### Core Features
|
||||
- **Group Management**: Create, edit, and manage expense groups with participants
|
||||
- **Expense Management**: Add, edit, delete expenses with various categories
|
||||
- **Split Modes**: Multiple ways to split expenses (evenly, by shares, by percentage, by amount)
|
||||
- **Balance Tracking**: View participant balances and what they owe/are owed
|
||||
- **Reimbursements**: Handle payments between participants
|
||||
- **Recurring Expenses**: Set up expenses that repeat (daily, weekly, monthly)
|
||||
- **Document Attachments**: Attach receipts/documents to expenses
|
||||
- **Activity Tracking**: View history of group activities
|
||||
- **Export Functionality**: Export data in CSV/JSON formats
|
||||
- **Statistics**: View spending analytics and trends
|
||||
|
||||
### Technical Details
|
||||
- Built with Next.js 14, TypeScript, tRPC, Prisma
|
||||
- Uses PostgreSQL database
|
||||
- Supports multiple currencies
|
||||
- Internationalized (i18n) interface
|
||||
- PWA capabilities
|
||||
|
||||
## Implementation Constraints
|
||||
|
||||
### Test ID Strategy
|
||||
During the migration, **the only modification allowed to application files is adding test IDs** (`data-testid` attributes). This constraint ensures:
|
||||
- Minimal impact on production code
|
||||
- No risk of introducing bugs through test implementation
|
||||
- Clean separation between test infrastructure and application logic
|
||||
- Easy identification of test-specific elements
|
||||
|
||||
### Test ID Naming Convention
|
||||
- Use kebab-case format: `data-testid="expense-card"`
|
||||
- Be descriptive and specific: `data-testid="balance-amount-john"`
|
||||
- Include context when needed: `data-testid="group-participant-list"`
|
||||
- Avoid generic names: prefer `expense-title-input` over `input`
|
||||
|
||||
### Test ID Implementation Guidelines
|
||||
1. **Strategic Placement**: Add test IDs only where needed for reliable element selection
|
||||
2. **Minimal Footprint**: Don't add test IDs to every element, focus on key interaction points
|
||||
3. **Future-Proof**: Choose stable elements that are unlikely to change frequently
|
||||
4. **Documentation**: Maintain a registry of added test IDs for reference
|
||||
|
||||
### Test Development Workflow
|
||||
**Mandatory Process**: After writing any new test, the development process **must run** `npm run test:e2e` to verify that:
|
||||
- The new test passes successfully
|
||||
- No existing tests are broken
|
||||
- All test interactions work as expected
|
||||
- Test reliability is maintained
|
||||
|
||||
This validation step is **required before continuing** with additional test development or implementation work.
|
||||
|
||||
## Priority-Based Testing Strategy
|
||||
|
||||
### Priority 1: Critical User Journeys (Must Have)
|
||||
|
||||
These are the core workflows that users perform most frequently:
|
||||
|
||||
#### 1. Group Creation and Management
|
||||
- **Test**: `group-lifecycle.spec.ts`
|
||||
- **Coverage**:
|
||||
- Create a new group with basic information
|
||||
- Add participants to the group
|
||||
- Edit group details (name, currency, information)
|
||||
- Navigate between group tabs (expenses, balances, information, stats, activity, settings)
|
||||
|
||||
#### 2. Basic Expense Management
|
||||
- **Test**: `expense-basic.spec.ts`
|
||||
- **Coverage**:
|
||||
- Create simple expense (equal split)
|
||||
- Edit existing expense
|
||||
- Delete expense
|
||||
- View expense details
|
||||
- Add expense notes
|
||||
|
||||
#### 3. Balance Calculation and Viewing
|
||||
- **Test**: `balances-and-reimbursements.spec.ts`
|
||||
- **Coverage**:
|
||||
- View participant balances after expenses
|
||||
- Verify balance calculations are correct
|
||||
- Create reimbursement from balance view
|
||||
- Mark reimbursements as completed
|
||||
|
||||
### Priority 2: Advanced Features (Should Have)
|
||||
|
||||
#### 4. Complex Expense Splitting
|
||||
- **Test**: `expense-splitting.spec.ts`
|
||||
- **Coverage**:
|
||||
- Split by shares
|
||||
- Split by percentage
|
||||
- Split by specific amounts
|
||||
- Exclude participants from expenses
|
||||
- Verify split calculations
|
||||
|
||||
#### 5. Categories and Organization
|
||||
- **Test**: `categories-and-organization.spec.ts`
|
||||
- **Coverage**:
|
||||
- Assign categories to expenses
|
||||
- Filter expenses by category
|
||||
- View category-based statistics
|
||||
|
||||
#### 6. Reimbursement Workflows
|
||||
- **Test**: `reimbursement-flows.spec.ts`
|
||||
- **Coverage**:
|
||||
- Create direct reimbursement
|
||||
- Generate reimbursement from suggestion
|
||||
- Track reimbursement status
|
||||
- Multiple reimbursement scenarios
|
||||
|
||||
### Priority 3: Advanced Functionality (Could Have)
|
||||
|
||||
#### 7. Recurring Expenses
|
||||
- **Test**: `recurring-expenses.spec.ts`
|
||||
- **Coverage**:
|
||||
- Set up daily recurring expense
|
||||
- Set up weekly recurring expense
|
||||
- Set up monthly recurring expense
|
||||
- Edit/cancel recurring expenses
|
||||
|
||||
#### 8. Document Management
|
||||
- **Test**: `document-management.spec.ts`
|
||||
- **Coverage**:
|
||||
- Upload receipt to expense
|
||||
- View attached documents
|
||||
- Remove documents
|
||||
- Multiple document attachments
|
||||
|
||||
#### 9. Data Export and Statistics
|
||||
- **Test**: `export-and-stats.spec.ts`
|
||||
- **Coverage**:
|
||||
- Export group data as CSV
|
||||
- Export group data as JSON
|
||||
- View spending statistics
|
||||
- Verify statistical calculations
|
||||
|
||||
#### 10. Activity Tracking
|
||||
- **Test**: `activity-tracking.spec.ts`
|
||||
- **Coverage**:
|
||||
- View activity feed
|
||||
- Verify activity entries for various actions
|
||||
- Activity timestamp accuracy
|
||||
|
||||
### Priority 4: Edge Cases and Error Handling (Nice to Have)
|
||||
|
||||
#### 11. Error Scenarios
|
||||
- **Test**: `error-handling.spec.ts`
|
||||
- **Coverage**:
|
||||
- Invalid input validation
|
||||
- Network error recovery
|
||||
- Large group management
|
||||
- Currency formatting edge cases
|
||||
|
||||
#### 12. Multi-User Workflows
|
||||
- **Test**: `multi-user-scenarios.spec.ts`
|
||||
- **Coverage**:
|
||||
- Multiple users in same group
|
||||
- Concurrent expense creation
|
||||
- Conflict resolution
|
||||
|
||||
## Test Implementation Structure
|
||||
|
||||
### Page Object Model (POM) Extensions
|
||||
|
||||
Extend the existing POM classes:
|
||||
|
||||
```typescript
|
||||
// Additional POM classes needed:
|
||||
- BalancePage.ts // For balance viewing and interactions
|
||||
- ReimbursementPage.ts // For reimbursement workflows
|
||||
- StatisticsPage.ts // For stats and analytics
|
||||
- ActivityPage.ts // For activity tracking
|
||||
- ExportPage.ts // For data export functionality
|
||||
- SettingsPage.ts // For group settings and management
|
||||
```
|
||||
|
||||
### Test Data Management
|
||||
|
||||
```typescript
|
||||
// test-data/
|
||||
- groups.ts // Test group configurations
|
||||
- expenses.ts // Various expense scenarios
|
||||
- participants.ts // Participant data sets
|
||||
- currencies.ts // Different currency formats
|
||||
```
|
||||
|
||||
### Utility Functions
|
||||
|
||||
```typescript
|
||||
// utils/
|
||||
- calculations.ts // Balance and split calculation helpers
|
||||
- currency.ts // Currency formatting utilities
|
||||
- date.ts // Date manipulation for recurring expenses
|
||||
- validation.ts // Input validation helpers
|
||||
```
|
||||
|
||||
## Test Execution Strategy
|
||||
|
||||
### Test Suites Organization
|
||||
|
||||
1. **Smoke Tests** (`smoke/`): Critical path tests that run on every commit
|
||||
- Basic group creation
|
||||
- Simple expense creation
|
||||
- Balance viewing
|
||||
|
||||
2. **Regression Tests** (`regression/`): Full feature coverage
|
||||
- All Priority 1 and 2 tests
|
||||
- Run on PRs and releases
|
||||
|
||||
3. **Extended Tests** (`extended/`): Comprehensive coverage
|
||||
- All priorities including edge cases
|
||||
- Run nightly or on demand
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- **Parallel Execution**: Group tests by functionality to run in parallel
|
||||
- **Test Isolation**: Each test should create its own group/data
|
||||
- **Cleanup**: Implement proper test data cleanup
|
||||
- **Database**: Consider using test database with fast reset
|
||||
|
||||
### Browser Coverage
|
||||
|
||||
- **Primary**: Chrome (latest)
|
||||
- **Secondary**: Firefox, Safari, Edge
|
||||
- **Mobile**: Mobile Chrome and Safari viewports
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Coverage Goals
|
||||
- **Critical Paths**: 100% coverage
|
||||
- **Core Features**: 95% coverage
|
||||
- **Advanced Features**: 80% coverage
|
||||
- **Edge Cases**: 60% coverage
|
||||
|
||||
### Quality Gates
|
||||
- All Priority 1 tests must pass for deployment
|
||||
- No more than 2% flaky test rate
|
||||
- Test execution time under 30 minutes for full suite
|
||||
- 95% test reliability score
|
||||
|
||||
## Implementation Timeline
|
||||
|
||||
### Phase 1 (Week 1-2): Foundation
|
||||
- **Identify and add required test IDs** to application components
|
||||
- Extend POM architecture
|
||||
- Implement Priority 1 tests
|
||||
- Set up test data management
|
||||
- Basic CI integration
|
||||
|
||||
### Phase 2 (Week 3-4): Core Features
|
||||
- **Add additional test IDs** for Priority 2 features
|
||||
- Implement Priority 2 tests
|
||||
- Add utility functions
|
||||
- Enhance test reliability
|
||||
- Performance optimization
|
||||
|
||||
### Phase 3 (Week 5-6): Advanced Features
|
||||
- **Complete test ID coverage** for remaining features
|
||||
- Implement Priority 3 tests
|
||||
- Cross-browser testing setup
|
||||
- Test reporting and analytics
|
||||
- Documentation completion
|
||||
|
||||
### Phase 4 (Week 7-8): Polish and Maintenance
|
||||
- **Finalize test ID registry and documentation**
|
||||
- Priority 4 tests implementation
|
||||
- Test suite optimization
|
||||
- Maintenance procedures
|
||||
- Team training
|
||||
|
||||
## Maintenance and Evolution
|
||||
|
||||
### Regular Reviews
|
||||
- Monthly test suite review for relevance
|
||||
- Quarterly performance optimization
|
||||
- Bi-annual architecture assessment
|
||||
|
||||
### Continuous Improvement
|
||||
- Monitor test flakiness and fix root causes
|
||||
- Add tests for new features immediately
|
||||
- Update tests when UI/UX changes
|
||||
- Regular dependency updates
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
### Common Pitfalls
|
||||
- **Over-testing**: Focus on user value, not code coverage
|
||||
- **Flaky tests**: Implement proper waits and retries
|
||||
- **Slow execution**: Optimize test data and parallel execution
|
||||
- **Maintenance burden**: Keep tests simple and focused
|
||||
|
||||
### Mitigation Strategies
|
||||
- Regular test review and cleanup
|
||||
- Investment in test infrastructure
|
||||
- Clear ownership and responsibility
|
||||
- Automated test health monitoring
|
||||
- **Mandatory test validation**: Always run `npm run test:e2e` after each new test implementation
|
||||
|
||||
This plan provides a structured approach to achieving comprehensive E2E test coverage for Spliit while prioritizing the most critical user workflows and maintaining sustainable test maintenance practices.
|
||||
93
prds/e2e-testing-setup.md
Normal file
93
prds/e2e-testing-setup.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Setting Up E2E Testing with Playwright
|
||||
|
||||
Follow these steps to integrate Playwright for end-to-end testing in your application:
|
||||
|
||||
## Step 1: Install Playwright
|
||||
|
||||
Install Playwright along with its testing library by running:
|
||||
|
||||
```bash
|
||||
npm install --save-dev @playwright/test
|
||||
```
|
||||
|
||||
This command sets up Playwright to manage automated browser interactions.
|
||||
|
||||
## Step 2: Configure Playwright
|
||||
|
||||
Create a Playwright configuration file named `playwright.config.ts` at the root of your project with the following content:
|
||||
|
||||
```typescript
|
||||
import { PlaywrightTestConfig } from '@playwright/test';
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
testDir: './tests',
|
||||
timeout: 30000,
|
||||
retries: 1,
|
||||
use: {
|
||||
headless: true,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
ignoreHTTPSErrors: true,
|
||||
video: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { browserName: 'chromium' },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { browserName: 'firefox' },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { browserName: 'webkit' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
## Step 3: Add E2E Test
|
||||
|
||||
Create a `tests` directory in the root of your project. Add E2E tests under this directory.
|
||||
|
||||
Example test file `tests/example.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('basic test', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000'); // replace with your local dev URL
|
||||
await expect(page).toHaveTitle(/Your App Title/); // update with your app's title
|
||||
});
|
||||
```
|
||||
|
||||
## Step 4: Update Package.json
|
||||
|
||||
Add a script in `package.json` for running Playwright tests:
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
...
|
||||
"test:e2e": "playwright test"
|
||||
}
|
||||
```
|
||||
|
||||
## Step 5: Run the Test
|
||||
|
||||
Ensure your application is running locally, then execute your tests with:
|
||||
|
||||
```bash
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Additional Considerations**
|
||||
|
||||
- If using CI/CD, adapt Playwright settings to accommodate environment constraints.
|
||||
- Manage environment variables as necessary for successful testing.
|
||||
- Utilize Playwright's `global-setup.js` and `global-teardown.js` for set up and tear down logic.
|
||||
|
||||
Follow these steps to effectively incorporate E2E testing into your build process using Playwright. For further customization or troubleshooting, consult Playwright's documentation.
|
||||
@@ -18,7 +18,7 @@ export function ActivityPageClient() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-4">
|
||||
<Card className="mb-4" data-testid="activity-content">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>{t('description')}</CardDescription>
|
||||
|
||||
@@ -34,8 +34,8 @@ export default function BalancesAndReimbursements() {
|
||||
const isLoading = balancesAreLoading || !balancesData || !group
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-4">
|
||||
<div data-testid="balances-content">
|
||||
<Card className="mb-4" data-testid="balances-card">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>{t('description')}</CardDescription>
|
||||
@@ -72,7 +72,7 @@ export default function BalancesAndReimbursements() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ export const EditGroup = () => {
|
||||
if (isLoading) return <></>
|
||||
|
||||
return (
|
||||
<GroupForm
|
||||
<div data-testid="edit-content">
|
||||
<GroupForm
|
||||
group={data?.group}
|
||||
onSubmit={async (groupFormValues, participantId) => {
|
||||
await mutateAsync({ groupId, participantId, groupFormValues })
|
||||
@@ -21,5 +22,6 @@ export const EditGroup = () => {
|
||||
}}
|
||||
protectedParticipantIds={data?.participantsWithExpenses}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export function ExpenseCard({ expense, currency, groupId }: Props) {
|
||||
return (
|
||||
<div
|
||||
key={expense.id}
|
||||
data-expense-card
|
||||
className={cn(
|
||||
'flex justify-between sm:mx-6 px-4 sm:rounded-lg sm:pr-2 sm:pl-4 py-4 text-sm cursor-pointer hover:bg-accent gap-1 items-stretch',
|
||||
expense.isReimbursement && 'italic',
|
||||
@@ -73,6 +74,7 @@ export function ExpenseCard({ expense, currency, groupId }: Props) {
|
||||
'tabular-nums whitespace-nowrap',
|
||||
expense.isReimbursement ? 'italic' : 'font-bold',
|
||||
)}
|
||||
data-amount
|
||||
>
|
||||
{formatCurrency(currency, expense.amount, locale)}
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function GroupExpensesPageClient({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-4 rounded-none -mx-4 border-x-0 sm:border-x sm:rounded-lg sm:mx-0">
|
||||
<Card className="mb-4 rounded-none -mx-4 border-x-0 sm:border-x sm:rounded-lg sm:mx-0" data-testid="expenses-content">
|
||||
<div className="flex flex-1">
|
||||
<CardHeader className="flex-1 p-4 sm:p-6">
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
|
||||
@@ -16,7 +16,9 @@ export const GroupHeader = () => {
|
||||
{isLoading ? (
|
||||
<Skeleton className="mt-1.5 mb-1.5 h-5 w-32" />
|
||||
) : (
|
||||
<div className="flex">{group.name}</div>
|
||||
<div className="flex" data-testid="group-name">
|
||||
{group.name}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</h1>
|
||||
|
||||
@@ -23,12 +23,12 @@ export function GroupTabs({ groupId }: Props) {
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="expenses">{t('Expenses.title')}</TabsTrigger>
|
||||
<TabsTrigger value="balances">{t('Balances.title')}</TabsTrigger>
|
||||
<TabsTrigger value="information">{t('Information.title')}</TabsTrigger>
|
||||
<TabsTrigger value="stats">{t('Stats.title')}</TabsTrigger>
|
||||
<TabsTrigger value="activity">{t('Activity.title')}</TabsTrigger>
|
||||
<TabsTrigger value="edit">{t('Settings.title')}</TabsTrigger>
|
||||
<TabsTrigger value="expenses" data-testid="tab-expenses">{t('Expenses.title')}</TabsTrigger>
|
||||
<TabsTrigger value="balances" data-testid="tab-balances">{t('Balances.title')}</TabsTrigger>
|
||||
<TabsTrigger value="information" data-testid="tab-information">{t('Information.title')}</TabsTrigger>
|
||||
<TabsTrigger value="stats" data-testid="tab-stats">{t('Stats.title')}</TabsTrigger>
|
||||
<TabsTrigger value="activity" data-testid="tab-activity">{t('Activity.title')}</TabsTrigger>
|
||||
<TabsTrigger value="edit" data-testid="tab-edit">{t('Settings.title')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function GroupInformation({ groupId }: { groupId: string }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-4">
|
||||
<Card className="mb-4" data-testid="information-content">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex justify-between">
|
||||
<span>{t('title')}</span>
|
||||
|
||||
@@ -13,7 +13,7 @@ export function TotalsPageClient() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-4">
|
||||
<Card className="mb-4" data-testid="stats-content">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('Totals.title')}</CardTitle>
|
||||
<CardDescription>{t('Totals.description')}</CardDescription>
|
||||
|
||||
@@ -17,7 +17,7 @@ export function DeletePopup({ onDelete }: { onDelete: () => Promise<void> }) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Button variant="destructive" data-testid="delete-expense-button">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{t('label')}
|
||||
</Button>
|
||||
@@ -31,6 +31,7 @@ export function DeletePopup({ onDelete }: { onDelete: () => Promise<void> }) {
|
||||
variant="destructive"
|
||||
loadingContent="Deleting…"
|
||||
action={onDelete}
|
||||
data-testid="confirm-delete-button"
|
||||
>
|
||||
{t('yes')}
|
||||
</AsyncButton>
|
||||
|
||||
262
tests/balances-and-reimbursements.spec.ts
Normal file
262
tests/balances-and-reimbursements.spec.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { CreateGroupPage } from './pom/create-group-page'
|
||||
import { ExpensePage } from './pom/expense-page'
|
||||
import { GroupPage } from './pom/group-page'
|
||||
import { BalancePage } from './pom/balance-page'
|
||||
import { testExpenses, generateUniqueExpenseTitle } from './test-data/expenses'
|
||||
import { generateUniqueGroupName } from './test-data/groups'
|
||||
import { CalculationUtils } from './utils/calculations'
|
||||
import { ReliabilityUtils } from './utils/reliability'
|
||||
|
||||
test.describe('Balance Calculation and Reimbursements', () => {
|
||||
test('View participant balances after expenses', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
const expensePage = new ExpensePage(page)
|
||||
const balancePage = new BalancePage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
|
||||
await test.step('Set up test group with participants', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupName)
|
||||
await createGroupPage.fillCurrency('USD')
|
||||
await createGroupPage.addParticipant('Alice', 0)
|
||||
await createGroupPage.addParticipant('Bob', 1)
|
||||
await createGroupPage.submit()
|
||||
|
||||
// Wait for the group page to fully load
|
||||
await groupPage.waitForGroupPageLoad()
|
||||
await expect(groupPage.title).toHaveText(groupName)
|
||||
})
|
||||
|
||||
await test.step('Create expense paid by Alice', async () => {
|
||||
await groupPage.createExpense()
|
||||
|
||||
const expenseTitle = generateUniqueExpenseTitle()
|
||||
await expensePage.fillTitle(expenseTitle)
|
||||
await expensePage.fillAmount('20.00')
|
||||
await expensePage.selectPayer('Alice')
|
||||
await expensePage.submit()
|
||||
|
||||
// Verify expense was created
|
||||
const expenseCard = groupPage.getExpenseCard(expenseTitle)
|
||||
await expect(expenseCard).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('View balances and verify calculations', async () => {
|
||||
// Navigate to balances tab with enhanced reliability
|
||||
await ReliabilityUtils.navigateToTab(page, 'balances', /\/groups\/[^\/]+\/balances$/)
|
||||
|
||||
// Verify content loaded with multiple fallback strategies
|
||||
await ReliabilityUtils.verifyContentLoaded(page, [
|
||||
'balances-content',
|
||||
'balances-card',
|
||||
'text=Balances',
|
||||
'[data-testid="balances-content"]'
|
||||
])
|
||||
|
||||
// Alice paid $20, so she should be owed $10 (paid $20, owes $10)
|
||||
// Bob paid $0, so he should owe $10 (paid $0, owes $10)
|
||||
// The balances should sum to zero for the group
|
||||
})
|
||||
|
||||
await test.step('Create second expense paid by Bob', async () => {
|
||||
// Navigate back to expenses
|
||||
await page.getByTestId('tab-expenses').click()
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await groupPage.createExpense()
|
||||
|
||||
const expenseTitle2 = generateUniqueExpenseTitle()
|
||||
await expensePage.fillTitle(expenseTitle2)
|
||||
await expensePage.fillAmount('30.00')
|
||||
await expensePage.selectPayer('Bob')
|
||||
await expensePage.submit()
|
||||
})
|
||||
|
||||
await test.step('Verify updated balances', async () => {
|
||||
// Navigate to balances tab with enhanced reliability
|
||||
await ReliabilityUtils.navigateToTab(page, 'balances', /\/groups\/[^\/]+\/balances$/)
|
||||
|
||||
// Verify content loaded with multiple fallback strategies
|
||||
await ReliabilityUtils.verifyContentLoaded(page, [
|
||||
'balances-content',
|
||||
'balances-card',
|
||||
'text=Balances',
|
||||
'[data-testid="balances-content"]'
|
||||
])
|
||||
|
||||
// Now Alice: paid $20, owes $25 = balance -$5 (owes $5)
|
||||
// Now Bob: paid $30, owes $25 = balance +$5 (is owed $5)
|
||||
// Total expenses: $50, split evenly: $25 each
|
||||
})
|
||||
})
|
||||
|
||||
test('Multiple expenses with different amounts', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
const expensePage = new ExpensePage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
const expenses = [
|
||||
{ title: `Lunch ${Date.now()}`, amount: '24.00', payer: 'Alice' },
|
||||
{ title: `Coffee ${Date.now() + 1}`, amount: '8.00', payer: 'Bob' },
|
||||
{ title: `Dinner ${Date.now() + 2}`, amount: '48.00', payer: 'Charlie' }
|
||||
]
|
||||
|
||||
await test.step('Set up test group with three participants', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupName)
|
||||
await createGroupPage.fillCurrency('USD')
|
||||
await createGroupPage.addParticipant('Alice', 0)
|
||||
await createGroupPage.addParticipant('Bob', 1)
|
||||
await createGroupPage.addParticipant('Charlie', 2)
|
||||
await createGroupPage.submit()
|
||||
|
||||
// Wait for the group page to fully load
|
||||
await groupPage.waitForGroupPageLoad()
|
||||
})
|
||||
|
||||
await test.step('Create multiple expenses', async () => {
|
||||
for (const expense of expenses) {
|
||||
await groupPage.createExpense()
|
||||
|
||||
await expensePage.fillTitle(expense.title)
|
||||
await expensePage.fillAmount(expense.amount)
|
||||
await expensePage.selectPayer(expense.payer)
|
||||
await expensePage.submit()
|
||||
|
||||
// Verify expense was created
|
||||
const expenseCard = groupPage.getExpenseCard(expense.title)
|
||||
await expect(expenseCard).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
await test.step('View final balances', async () => {
|
||||
// Navigate to balances tab with enhanced reliability
|
||||
await ReliabilityUtils.navigateToTab(page, 'balances', /\/groups\/[^\/]+\/balances$/)
|
||||
|
||||
// Verify content loaded with multiple fallback strategies
|
||||
await ReliabilityUtils.verifyContentLoaded(page, [
|
||||
'balances-content',
|
||||
'balances-card',
|
||||
'text=Balances',
|
||||
'[data-testid="balances-content"]'
|
||||
])
|
||||
|
||||
// Total expenses: $24 + $8 + $48 = $80
|
||||
// Split 3 ways: $80 / 3 = $26.67 each
|
||||
// Alice: paid $24, owes $26.67 = balance -$2.67
|
||||
// Bob: paid $8, owes $26.67 = balance -$18.67
|
||||
// Charlie: paid $48, owes $26.67 = balance +$21.33
|
||||
// Balances should sum to zero: -2.67 + -18.67 + 21.33 = -0.01 (due to rounding)
|
||||
})
|
||||
})
|
||||
|
||||
test('Single person pays all expenses', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
const expensePage = new ExpensePage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
|
||||
await test.step('Set up test group', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupName)
|
||||
await createGroupPage.fillCurrency('USD')
|
||||
await createGroupPage.addParticipant('Payer', 0)
|
||||
await createGroupPage.addParticipant('Person1', 1)
|
||||
await createGroupPage.addParticipant('Person2', 2)
|
||||
await createGroupPage.submit()
|
||||
|
||||
// Wait for the group page to fully load
|
||||
await groupPage.waitForGroupPageLoad()
|
||||
})
|
||||
|
||||
await test.step('Create expenses all paid by same person', async () => {
|
||||
const expenses = [
|
||||
{ title: `Expense1 ${Date.now()}`, amount: '15.00' },
|
||||
{ title: `Expense2 ${Date.now() + 1}`, amount: '30.00' },
|
||||
{ title: `Expense3 ${Date.now() + 2}`, amount: '45.00' }
|
||||
]
|
||||
|
||||
for (const expense of expenses) {
|
||||
await groupPage.createExpense()
|
||||
|
||||
await expensePage.fillTitle(expense.title)
|
||||
await expensePage.fillAmount(expense.amount)
|
||||
await expensePage.selectPayer('Payer')
|
||||
await expensePage.submit()
|
||||
}
|
||||
})
|
||||
|
||||
await test.step('Verify balances show correct amounts owed', async () => {
|
||||
// Navigate to balances tab with enhanced reliability
|
||||
await ReliabilityUtils.navigateToTab(page, 'balances', /\/groups\/[^\/]+\/balances$/)
|
||||
|
||||
// Verify content loaded with multiple fallback strategies
|
||||
await ReliabilityUtils.verifyContentLoaded(page, [
|
||||
'balances-content',
|
||||
'balances-card',
|
||||
'text=Balances',
|
||||
'[data-testid="balances-content"]'
|
||||
])
|
||||
|
||||
// Total expenses: $15 + $30 + $45 = $90
|
||||
// Split 3 ways: $30 each
|
||||
// Payer: paid $90, owes $30 = balance +$60 (is owed $60)
|
||||
// Person1: paid $0, owes $30 = balance -$30 (owes $30)
|
||||
// Person2: paid $0, owes $30 = balance -$30 (owes $30)
|
||||
// Total: +60 - 30 - 30 = 0 ✓
|
||||
})
|
||||
})
|
||||
|
||||
test('Equal split verification', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
const expensePage = new ExpensePage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
|
||||
await test.step('Set up test group', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupName)
|
||||
await createGroupPage.fillCurrency('USD')
|
||||
await createGroupPage.addParticipant('User1', 0)
|
||||
await createGroupPage.addParticipant('User2', 1)
|
||||
await createGroupPage.submit()
|
||||
|
||||
// Wait for the group page to fully load
|
||||
await groupPage.waitForGroupPageLoad()
|
||||
})
|
||||
|
||||
await test.step('Create expense with even amount', async () => {
|
||||
await groupPage.createExpense()
|
||||
|
||||
const expenseTitle = generateUniqueExpenseTitle()
|
||||
await expensePage.fillTitle(expenseTitle)
|
||||
await expensePage.fillAmount('100.00')
|
||||
await expensePage.selectPayer('User1')
|
||||
await expensePage.submit()
|
||||
})
|
||||
|
||||
await test.step('Verify equal split calculation', async () => {
|
||||
// Navigate to balances tab with enhanced reliability
|
||||
await ReliabilityUtils.navigateToTab(page, 'balances', /\/balances$/)
|
||||
|
||||
// $100 split evenly between 2 people = $50 each
|
||||
// User1: paid $100, owes $50 = balance +$50 (is owed $50)
|
||||
// User2: paid $0, owes $50 = balance -$50 (owes $50)
|
||||
|
||||
// Verify content loaded with multiple fallback strategies
|
||||
await ReliabilityUtils.verifyContentLoaded(page, [
|
||||
'balances-content',
|
||||
'balances-card',
|
||||
'text=Balances',
|
||||
'[data-testid="balances-content"]',
|
||||
'text=USD'
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
38
tests/create-group.spec.ts
Normal file
38
tests/create-group.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { CreateGroupPage } from './pom/create-group-page'
|
||||
import { ExpensePage } from './pom/expense-page'
|
||||
import { GroupPage } from './pom/group-page'
|
||||
|
||||
test('Create a new group and add an expense', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
const expensePage = new ExpensePage(page)
|
||||
|
||||
await test.step('Create a new group', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName('New Test Group')
|
||||
await createGroupPage.fillCurrency('USD')
|
||||
await createGroupPage.fillAdditionalInfo('This is a test group.')
|
||||
await createGroupPage.addParticipant('John', 0)
|
||||
await createGroupPage.addParticipant('Jane', 1)
|
||||
await createGroupPage.submit()
|
||||
})
|
||||
|
||||
await test.step('Check that the group is created', async () => {
|
||||
await expect(groupPage.title).toHaveText('New Test Group')
|
||||
})
|
||||
|
||||
await test.step('Create an expense', async () => {
|
||||
await groupPage.createExpense()
|
||||
await expensePage.fillTitle('Coffee')
|
||||
await expensePage.fillAmount('4.5')
|
||||
await expensePage.selectPayer('John')
|
||||
await expensePage.submit()
|
||||
})
|
||||
|
||||
await test.step('Check that the expense is created', async () => {
|
||||
const expenseCard = groupPage.getExpenseCard('Coffee')
|
||||
await expect(expenseCard).toBeVisible()
|
||||
await expect(expenseCard.locator('[data-amount]')).toHaveText('USD4.50')
|
||||
})
|
||||
})
|
||||
45
tests/expense-basic-simple.spec.ts
Normal file
45
tests/expense-basic-simple.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { CreateGroupPage } from './pom/create-group-page'
|
||||
import { ExpensePage } from './pom/expense-page'
|
||||
import { GroupPage } from './pom/group-page'
|
||||
import { testExpenses, generateUniqueExpenseTitle } from './test-data/expenses'
|
||||
import { generateUniqueGroupName } from './test-data/groups'
|
||||
|
||||
test('Simple expense creation', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
const expensePage = new ExpensePage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
const expenseTitle = generateUniqueExpenseTitle()
|
||||
const expenseData = { ...testExpenses.simple, title: expenseTitle }
|
||||
|
||||
await test.step('Set up test group', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupName)
|
||||
await createGroupPage.fillCurrency('USD')
|
||||
await createGroupPage.addParticipant('Alice', 0)
|
||||
await createGroupPage.addParticipant('Bob', 1)
|
||||
await createGroupPage.submit()
|
||||
|
||||
await expect(groupPage.title).toHaveText(groupName)
|
||||
})
|
||||
|
||||
await test.step('Create new expense', async () => {
|
||||
await groupPage.createExpense()
|
||||
|
||||
await expensePage.fillTitle(expenseData.title)
|
||||
await expensePage.fillAmount(expenseData.amount)
|
||||
await expensePage.selectPayer('Alice')
|
||||
await expensePage.submit()
|
||||
})
|
||||
|
||||
await test.step('Verify expense is created and displayed', async () => {
|
||||
// Should navigate back to expenses page
|
||||
await expect(page).toHaveURL(/\/groups\/[^\/]+\/expenses$/)
|
||||
|
||||
const expenseCard = groupPage.getExpenseCard(expenseData.title)
|
||||
await expect(expenseCard).toBeVisible()
|
||||
await expect(expenseCard.locator('[data-amount]')).toHaveText('USD4.50')
|
||||
})
|
||||
})
|
||||
176
tests/expense-basic.spec.ts
Normal file
176
tests/expense-basic.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { CreateGroupPage } from './pom/create-group-page'
|
||||
import { ExpensePage } from './pom/expense-page'
|
||||
import { GroupPage } from './pom/group-page'
|
||||
import { testExpenses, generateUniqueExpenseTitle } from './test-data/expenses'
|
||||
import { generateUniqueGroupName } from './test-data/groups'
|
||||
|
||||
test.describe('Basic Expense Management', () => {
|
||||
test('Create, view, edit, and delete expense', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
const expensePage = new ExpensePage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
const expenseTitle = generateUniqueExpenseTitle()
|
||||
const expenseData = { ...testExpenses.simple, title: expenseTitle }
|
||||
|
||||
await test.step('Set up test group', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupName)
|
||||
await createGroupPage.fillCurrency('USD')
|
||||
await createGroupPage.addParticipant('Alice', 0)
|
||||
await createGroupPage.addParticipant('Bob', 1)
|
||||
await createGroupPage.submit()
|
||||
|
||||
await expect(groupPage.title).toHaveText(groupName)
|
||||
})
|
||||
|
||||
await test.step('Create new expense', async () => {
|
||||
await groupPage.createExpense()
|
||||
|
||||
await expensePage.fillTitle(expenseData.title)
|
||||
await expensePage.fillAmount(expenseData.amount)
|
||||
await expensePage.selectPayer('Alice')
|
||||
await expensePage.submit()
|
||||
})
|
||||
|
||||
await test.step('Verify expense is created and displayed', async () => {
|
||||
const expenseCard = groupPage.getExpenseCard(expenseData.title)
|
||||
await expect(expenseCard).toBeVisible()
|
||||
await expect(expenseCard.locator('[data-amount]')).toHaveText('USD4.50')
|
||||
})
|
||||
|
||||
await test.step('Edit the expense', async () => {
|
||||
const expenseCard = groupPage.getExpenseCard(expenseData.title)
|
||||
await expenseCard.click()
|
||||
|
||||
// Should navigate to expense edit page
|
||||
await expect(page).toHaveURL(/\/expenses\/[^\/]+\/edit$/)
|
||||
|
||||
// Update the expense
|
||||
const newTitle = `${expenseData.title} - Updated`
|
||||
const newAmount = '6.75'
|
||||
|
||||
await expensePage.fillTitle(newTitle)
|
||||
await expensePage.fillAmount(newAmount)
|
||||
await expensePage.submit()
|
||||
|
||||
// Verify we're back to the group expenses page
|
||||
await expect(page).toHaveURL(/\/groups\/[^\/]+\/expenses$/)
|
||||
})
|
||||
|
||||
await test.step('Verify expense was updated', async () => {
|
||||
const updatedExpenseCard = groupPage.getExpenseCard(`${expenseData.title} - Updated`)
|
||||
await expect(updatedExpenseCard).toBeVisible()
|
||||
await expect(updatedExpenseCard.locator('[data-amount]')).toHaveText('USD6.75')
|
||||
})
|
||||
|
||||
await test.step('Delete the expense', async () => {
|
||||
const expenseCard = groupPage.getExpenseCard(`${expenseData.title} - Updated`)
|
||||
await expenseCard.click()
|
||||
|
||||
// Should be on edit page
|
||||
await expect(page).toHaveURL(/\/expenses\/[^\/]+\/edit$/)
|
||||
|
||||
// Click delete button
|
||||
await page.getByTestId('delete-expense-button').click()
|
||||
|
||||
// Confirm deletion
|
||||
await page.getByTestId('confirm-delete-button').click()
|
||||
|
||||
// Should be back to group expenses page
|
||||
await expect(page).toHaveURL(/\/groups\/[^\/]+\/expenses$/)
|
||||
})
|
||||
|
||||
await test.step('Verify expense was deleted', async () => {
|
||||
// The expense should no longer be visible
|
||||
const expenseCard = groupPage.getExpenseCard(`${expenseData.title} - Updated`)
|
||||
await expect(expenseCard).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test('Create expense with notes', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
const expensePage = new ExpensePage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
const expenseTitle = generateUniqueExpenseTitle()
|
||||
const expenseData = { ...testExpenses.restaurant, title: expenseTitle }
|
||||
|
||||
await test.step('Set up test group', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupName)
|
||||
await createGroupPage.fillCurrency('USD')
|
||||
await createGroupPage.addParticipant('John', 0)
|
||||
await createGroupPage.addParticipant('Jane', 1)
|
||||
await createGroupPage.submit()
|
||||
})
|
||||
|
||||
await test.step('Create expense with notes', async () => {
|
||||
await groupPage.createExpense()
|
||||
|
||||
await expensePage.fillTitle(expenseData.title)
|
||||
await expensePage.fillAmount(expenseData.amount)
|
||||
await expensePage.selectPayer('John')
|
||||
|
||||
// Add notes if the field exists
|
||||
const notesField = page.getByTestId('expense-notes-input')
|
||||
if (await notesField.isVisible()) {
|
||||
await notesField.fill(expenseData.notes)
|
||||
}
|
||||
|
||||
await expensePage.submit()
|
||||
})
|
||||
|
||||
await test.step('Verify expense with notes is created', async () => {
|
||||
const expenseCard = groupPage.getExpenseCard(expenseData.title)
|
||||
await expect(expenseCard).toBeVisible()
|
||||
await expect(expenseCard.locator('[data-amount]')).toHaveText('USD85.20')
|
||||
})
|
||||
})
|
||||
|
||||
test('Create multiple expenses and verify list', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
const expensePage = new ExpensePage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
const expenses = [
|
||||
{ ...testExpenses.coffee, title: `Coffee ${Date.now()}` },
|
||||
{ ...testExpenses.transport, title: `Transport ${Date.now() + 1}` },
|
||||
{ ...testExpenses.grocery, title: `Grocery ${Date.now() + 2}` }
|
||||
]
|
||||
|
||||
await test.step('Set up test group', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupName)
|
||||
await createGroupPage.fillCurrency('USD')
|
||||
await createGroupPage.addParticipant('User1', 0)
|
||||
await createGroupPage.addParticipant('User2', 1)
|
||||
await createGroupPage.submit()
|
||||
})
|
||||
|
||||
await test.step('Create multiple expenses', async () => {
|
||||
for (const expense of expenses) {
|
||||
await groupPage.createExpense()
|
||||
|
||||
await expensePage.fillTitle(expense.title)
|
||||
await expensePage.fillAmount(expense.amount)
|
||||
await expensePage.selectPayer('User1')
|
||||
await expensePage.submit()
|
||||
|
||||
// Wait for navigation back to group expenses page
|
||||
await expect(page).toHaveURL(/\/groups\/[^\/]+\/expenses$/)
|
||||
}
|
||||
})
|
||||
|
||||
await test.step('Verify all expenses are listed', async () => {
|
||||
for (const expense of expenses) {
|
||||
const expenseCard = groupPage.getExpenseCard(expense.title)
|
||||
await expect(expenseCard).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
36
tests/group-lifecycle-simple.spec.ts
Normal file
36
tests/group-lifecycle-simple.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { CreateGroupPage } from './pom/create-group-page'
|
||||
import { GroupPage } from './pom/group-page'
|
||||
import { generateUniqueGroupName } from './test-data/groups'
|
||||
|
||||
test('Simple group creation and tab navigation', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
|
||||
await test.step('Create a new group', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupName)
|
||||
await createGroupPage.fillCurrency('USD')
|
||||
await createGroupPage.addParticipant('Alice', 0)
|
||||
await createGroupPage.addParticipant('Bob', 1)
|
||||
await createGroupPage.submit()
|
||||
})
|
||||
|
||||
await test.step('Verify group is created', async () => {
|
||||
await expect(groupPage.title).toHaveText(groupName)
|
||||
})
|
||||
|
||||
await test.step('Test basic tab navigation', async () => {
|
||||
// Navigate to balances tab
|
||||
await page.getByTestId('tab-balances').click()
|
||||
await expect(page).toHaveURL(/\/balances$/)
|
||||
await expect(page.getByTestId('balances-content')).toBeVisible()
|
||||
|
||||
// Navigate back to expenses tab
|
||||
await page.getByTestId('tab-expenses').click()
|
||||
await expect(page).toHaveURL(/\/expenses$/)
|
||||
await expect(page.getByTestId('expenses-content')).toBeVisible()
|
||||
})
|
||||
})
|
||||
133
tests/group-lifecycle.spec.ts
Normal file
133
tests/group-lifecycle.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { CreateGroupPage } from './pom/create-group-page'
|
||||
import { GroupPage } from './pom/group-page'
|
||||
import { SettingsPage } from './pom/settings-page'
|
||||
import { testGroups, generateUniqueGroupName } from './test-data/groups'
|
||||
|
||||
test.describe('Group Lifecycle Management', () => {
|
||||
test('Complete group lifecycle: create, navigate tabs, edit details', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
const settingsPage = new SettingsPage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
const groupData = { ...testGroups.basic, name: groupName }
|
||||
|
||||
await test.step('Create a new group with participants', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupData.name)
|
||||
await createGroupPage.fillCurrency(groupData.currency)
|
||||
await createGroupPage.fillAdditionalInfo(groupData.information)
|
||||
|
||||
// Add participants
|
||||
for (let i = 0; i < groupData.participants.length; i++) {
|
||||
await createGroupPage.addParticipant(groupData.participants[i], i)
|
||||
}
|
||||
|
||||
await createGroupPage.submit()
|
||||
})
|
||||
|
||||
await test.step('Verify group is created and displayed correctly', async () => {
|
||||
// Wait for the group page to fully load
|
||||
await groupPage.waitForGroupPageLoad()
|
||||
await expect(groupPage.title).toHaveText(groupData.name)
|
||||
await expect(page).toHaveURL(/\/groups\/[^\/]+/)
|
||||
})
|
||||
|
||||
await test.step('Navigate between group tabs', async () => {
|
||||
// Test navigation to each tab
|
||||
const tabs = [
|
||||
{ name: 'expenses', content: 'expenses-content' },
|
||||
{ name: 'balances', content: 'balances-content' },
|
||||
{ name: 'information', content: 'information-content' },
|
||||
{ name: 'stats', content: 'stats-content' },
|
||||
{ name: 'activity', content: 'activity-content' },
|
||||
{ name: 'edit', content: 'edit-content' }
|
||||
]
|
||||
|
||||
for (const tab of tabs) {
|
||||
await page.getByTestId(`tab-${tab.name}`).click()
|
||||
// Wait for navigation to complete
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(page).toHaveURL(new RegExp(`\\/groups\\/[^\\/]+\\/${tab.name}`))
|
||||
|
||||
// Verify tab content loads with retry
|
||||
await expect(page.getByTestId(tab.content)).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
})
|
||||
|
||||
await test.step('Verify we can access edit page', async () => {
|
||||
// Navigate to settings if not already there
|
||||
await page.getByTestId('tab-edit').click()
|
||||
|
||||
// Just verify we can access the edit content
|
||||
await expect(page.getByTestId('edit-content')).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Return to expenses tab', async () => {
|
||||
// Navigate back to main group page
|
||||
await page.getByTestId('tab-expenses').click()
|
||||
await expect(page.getByTestId('expenses-content')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test('Create minimal group with two participants', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
const groupData = { ...testGroups.minimal, name: groupName }
|
||||
|
||||
await test.step('Create minimal group', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupData.name)
|
||||
await createGroupPage.fillCurrency(groupData.currency)
|
||||
|
||||
// Add only two participants
|
||||
await createGroupPage.addParticipant(groupData.participants[0], 0)
|
||||
await createGroupPage.addParticipant(groupData.participants[1], 1)
|
||||
|
||||
await createGroupPage.submit()
|
||||
})
|
||||
|
||||
await test.step('Verify minimal group creation', async () => {
|
||||
// Wait for the group page to fully load
|
||||
await groupPage.waitForGroupPageLoad()
|
||||
await expect(groupPage.title).toHaveText(groupData.name)
|
||||
|
||||
// Verify we're on the expenses page
|
||||
await expect(page.getByTestId('expenses-content')).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
})
|
||||
|
||||
test('Create group with three participants', async ({ page }) => {
|
||||
const createGroupPage = new CreateGroupPage(page)
|
||||
const groupPage = new GroupPage(page)
|
||||
|
||||
const groupName = generateUniqueGroupName()
|
||||
const groupData = { ...testGroups.basic, name: groupName }
|
||||
|
||||
await test.step('Create group with 3 participants', async () => {
|
||||
await createGroupPage.navigate()
|
||||
await createGroupPage.fillGroupName(groupData.name)
|
||||
await createGroupPage.fillCurrency(groupData.currency)
|
||||
await createGroupPage.fillAdditionalInfo(groupData.information)
|
||||
|
||||
// Add 3 participants
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await createGroupPage.addParticipant(groupData.participants[i], i)
|
||||
}
|
||||
|
||||
await createGroupPage.submit()
|
||||
})
|
||||
|
||||
await test.step('Verify group with 3 participants is created', async () => {
|
||||
// Wait for the group page to fully load
|
||||
await groupPage.waitForGroupPageLoad()
|
||||
await expect(groupPage.title).toHaveText(groupData.name)
|
||||
|
||||
// Verify we're on the expenses page
|
||||
await expect(page.getByTestId('expenses-content')).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
})
|
||||
})
|
||||
37
tests/pom/balance-page.ts
Normal file
37
tests/pom/balance-page.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class BalancePage {
|
||||
page: Page
|
||||
balancesSection: Locator
|
||||
reimbursementsSection: Locator
|
||||
balancesList: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
this.balancesSection = page.getByTestId('balances-section')
|
||||
this.reimbursementsSection = page.getByTestId('reimbursements-section')
|
||||
this.balancesList = page.getByTestId('balances-list')
|
||||
}
|
||||
|
||||
async navigateToGroupBalances(groupId: string) {
|
||||
await this.page.goto(`http://localhost:3002/groups/${groupId}/balances`)
|
||||
}
|
||||
|
||||
async getParticipantBalance(participantName: string) {
|
||||
return this.page.getByTestId(`balance-${participantName.toLowerCase()}`)
|
||||
}
|
||||
|
||||
async getBalanceAmount(participantName: string) {
|
||||
const balanceElement = await this.getParticipantBalance(participantName)
|
||||
return balanceElement.getByTestId('balance-amount')
|
||||
}
|
||||
|
||||
async createReimbursementFromBalance(fromParticipant: string, toParticipant: string) {
|
||||
const reimbursementButton = this.page.getByTestId(`create-reimbursement-${fromParticipant}-${toParticipant}`)
|
||||
await reimbursementButton.click()
|
||||
}
|
||||
|
||||
async getReimbursementSuggestion(fromParticipant: string, toParticipant: string) {
|
||||
return this.page.getByTestId(`reimbursement-suggestion-${fromParticipant}-${toParticipant}`)
|
||||
}
|
||||
}
|
||||
36
tests/pom/create-group-page.ts
Normal file
36
tests/pom/create-group-page.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Page } from '@playwright/test'
|
||||
|
||||
export class CreateGroupPage {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async navigate() {
|
||||
await this.page.goto('http://localhost:3002/groups/create')
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
async fillGroupName(name: string) {
|
||||
await this.page.getByRole('textbox', { name: 'Group name' }).fill(name)
|
||||
}
|
||||
|
||||
async fillCurrency(currency: string) {
|
||||
await this.page.getByRole('textbox', { name: 'Currency' }).fill(currency)
|
||||
}
|
||||
|
||||
async fillAdditionalInfo(info: string) {
|
||||
await this.page
|
||||
.getByRole('textbox', { name: 'Group Information' })
|
||||
.fill(info)
|
||||
}
|
||||
|
||||
async addParticipant(participantName: string, index: number) {
|
||||
await this.page
|
||||
.locator(`input[name="participants.${index}.name"]`)
|
||||
.fill(participantName)
|
||||
}
|
||||
|
||||
async submit() {
|
||||
await this.page.getByRole('button', { name: 'Create' }).click()
|
||||
// Wait for navigation to complete
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
}
|
||||
47
tests/pom/expense-page.ts
Normal file
47
tests/pom/expense-page.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Page } from '@playwright/test'
|
||||
|
||||
export class ExpensePage {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async navigateToGroupExpenses(groupId: string) {
|
||||
await this.page.goto(`http://localhost:3002/groups/${groupId}/expenses`)
|
||||
}
|
||||
|
||||
async fillTitle(expenseTitle: string) {
|
||||
await this.page
|
||||
.getByRole('textbox', { name: 'Expense title' })
|
||||
.fill(expenseTitle)
|
||||
}
|
||||
|
||||
async fillAmount(expenseAmount: string) {
|
||||
await this.page.getByRole('textbox', { name: 'Amount' }).fill(expenseAmount)
|
||||
}
|
||||
|
||||
async selectPayer(payer: string) {
|
||||
await this.page
|
||||
.getByRole('combobox')
|
||||
.filter({ hasText: 'Select a participant' })
|
||||
.click()
|
||||
await this.page.getByRole('option', { name: payer, exact: true }).click()
|
||||
}
|
||||
|
||||
async submit() {
|
||||
// Look for either Create or Save button
|
||||
const createButton = this.page.getByRole('button', { name: 'Create' })
|
||||
const saveButton = this.page.getByRole('button', { name: 'Save' })
|
||||
|
||||
if (await createButton.isVisible()) {
|
||||
await createButton.click()
|
||||
} else {
|
||||
await saveButton.click()
|
||||
}
|
||||
|
||||
// Wait for navigation to complete
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
async waitForPageLoad() {
|
||||
// Wait for the expense form to be fully loaded
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
}
|
||||
41
tests/pom/group-page.ts
Normal file
41
tests/pom/group-page.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class GroupPage {
|
||||
page: Page
|
||||
title: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
this.title = page.getByTestId('group-name')
|
||||
}
|
||||
|
||||
async createExpense() {
|
||||
// Wait for the page to be in a stable state before clicking
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
|
||||
// Retry clicking the create expense link if it fails
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
await this.page.getByRole('link', { name: 'Create expense' }).click({ timeout: 5000 })
|
||||
break
|
||||
} catch (error) {
|
||||
if (attempt === 2) throw error
|
||||
await this.page.waitForTimeout(1000)
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async waitForGroupPageLoad() {
|
||||
// Wait for group name to be visible
|
||||
await this.title.waitFor({ state: 'visible' })
|
||||
// Wait for network to be idle
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
getExpenseCard(expenseTitle: string) {
|
||||
return this.page
|
||||
.locator('[data-expense-card]')
|
||||
.filter({ hasText: expenseTitle })
|
||||
}
|
||||
}
|
||||
52
tests/pom/settings-page.ts
Normal file
52
tests/pom/settings-page.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class SettingsPage {
|
||||
page: Page
|
||||
groupNameInput: Locator
|
||||
currencyInput: Locator
|
||||
informationTextarea: Locator
|
||||
saveButton: Locator
|
||||
participantsList: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
this.groupNameInput = page.getByTestId('group-name-input')
|
||||
this.currencyInput = page.getByTestId('group-currency-input')
|
||||
this.informationTextarea = page.getByTestId('group-information-input')
|
||||
this.saveButton = page.getByTestId('save-group-button')
|
||||
this.participantsList = page.getByTestId('participants-list')
|
||||
}
|
||||
|
||||
async navigateToGroupSettings(groupId: string) {
|
||||
await this.page.goto(`http://localhost:3002/groups/${groupId}/edit`)
|
||||
}
|
||||
|
||||
async updateGroupName(newName: string) {
|
||||
await this.groupNameInput.fill(newName)
|
||||
}
|
||||
|
||||
async updateCurrency(newCurrency: string) {
|
||||
await this.currencyInput.fill(newCurrency)
|
||||
}
|
||||
|
||||
async updateInformation(newInfo: string) {
|
||||
await this.informationTextarea.fill(newInfo)
|
||||
}
|
||||
|
||||
async addParticipant(participantName: string) {
|
||||
const addButton = this.page.getByTestId('add-participant-button')
|
||||
await addButton.click()
|
||||
|
||||
const newParticipantInput = this.page.getByTestId('new-participant-input')
|
||||
await newParticipantInput.fill(participantName)
|
||||
}
|
||||
|
||||
async removeParticipant(participantName: string) {
|
||||
const removeButton = this.page.getByTestId(`remove-participant-${participantName}`)
|
||||
await removeButton.click()
|
||||
}
|
||||
|
||||
async saveChanges() {
|
||||
await this.saveButton.click()
|
||||
}
|
||||
}
|
||||
62
tests/test-data/expenses.ts
Normal file
62
tests/test-data/expenses.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export const testExpenses = {
|
||||
simple: {
|
||||
title: 'Coffee',
|
||||
amount: '4.50',
|
||||
category: 'Food & Drinks',
|
||||
notes: 'Morning coffee'
|
||||
},
|
||||
|
||||
coffee: {
|
||||
title: 'Coffee',
|
||||
amount: '4.50',
|
||||
category: 'Food & Drinks',
|
||||
notes: 'Morning coffee'
|
||||
},
|
||||
|
||||
restaurant: {
|
||||
title: 'Dinner at Restaurant',
|
||||
amount: '85.20',
|
||||
category: 'Food & Drinks',
|
||||
notes: 'Group dinner'
|
||||
},
|
||||
|
||||
grocery: {
|
||||
title: 'Grocery Shopping',
|
||||
amount: '156.78',
|
||||
category: 'Food & Drinks',
|
||||
notes: 'Weekly groceries'
|
||||
},
|
||||
|
||||
transport: {
|
||||
title: 'Taxi Ride',
|
||||
amount: '23.50',
|
||||
category: 'Transportation',
|
||||
notes: 'Airport transfer'
|
||||
},
|
||||
|
||||
accommodation: {
|
||||
title: 'Hotel Stay',
|
||||
amount: '320.00',
|
||||
category: 'Accommodation',
|
||||
notes: '2 nights hotel booking'
|
||||
},
|
||||
|
||||
entertainment: {
|
||||
title: 'Movie Tickets',
|
||||
amount: '42.00',
|
||||
category: 'Entertainment',
|
||||
notes: 'Cinema tickets for 3 people'
|
||||
}
|
||||
}
|
||||
|
||||
export const splitModes = {
|
||||
evenly: 'EVENLY',
|
||||
byShares: 'BY_SHARES',
|
||||
byPercentage: 'BY_PERCENTAGE',
|
||||
byAmount: 'BY_AMOUNT'
|
||||
}
|
||||
|
||||
export const generateUniqueExpenseTitle = () => {
|
||||
const timestamp = Date.now()
|
||||
return `Test Expense ${timestamp}`
|
||||
}
|
||||
34
tests/test-data/groups.ts
Normal file
34
tests/test-data/groups.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const testGroups = {
|
||||
basic: {
|
||||
name: 'Test Group',
|
||||
currency: 'USD',
|
||||
information: 'A test group for E2E testing',
|
||||
participants: ['Alice', 'Bob', 'Charlie']
|
||||
},
|
||||
|
||||
family: {
|
||||
name: 'Family Expenses',
|
||||
currency: 'EUR',
|
||||
information: 'Family expense tracking',
|
||||
participants: ['Mom', 'Dad', 'Sister', 'Brother']
|
||||
},
|
||||
|
||||
vacation: {
|
||||
name: 'Summer Vacation 2024',
|
||||
currency: 'USD',
|
||||
information: 'Vacation expenses for the group trip',
|
||||
participants: ['John', 'Jane', 'Mike', 'Sarah', 'Tom']
|
||||
},
|
||||
|
||||
minimal: {
|
||||
name: 'Two Person Group',
|
||||
currency: 'USD',
|
||||
information: '',
|
||||
participants: ['Person1', 'Person2']
|
||||
}
|
||||
}
|
||||
|
||||
export const generateUniqueGroupName = () => {
|
||||
const timestamp = Date.now()
|
||||
return `Test Group ${timestamp}`
|
||||
}
|
||||
57
tests/utils/calculations.ts
Normal file
57
tests/utils/calculations.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export class CalculationUtils {
|
||||
/**
|
||||
* Calculate expected balance for a participant
|
||||
*/
|
||||
static calculateExpectedBalance(
|
||||
participantExpenses: number[],
|
||||
participantShares: number[]
|
||||
): number {
|
||||
const totalPaid = participantExpenses.reduce((sum, expense) => sum + expense, 0)
|
||||
const totalOwed = participantShares.reduce((sum, share) => sum + share, 0)
|
||||
|
||||
return totalPaid - totalOwed
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate even split amount
|
||||
*/
|
||||
static calculateEvenSplit(totalAmount: number, participantCount: number): number {
|
||||
return totalAmount / participantCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate split by percentage
|
||||
*/
|
||||
static calculatePercentageSplit(totalAmount: number, percentage: number): number {
|
||||
return (totalAmount * percentage) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate split by shares
|
||||
*/
|
||||
static calculateShareSplit(totalAmount: number, shares: number, totalShares: number): number {
|
||||
return (totalAmount * shares) / totalShares
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency amount to 2 decimal places
|
||||
*/
|
||||
static formatCurrency(amount: number, currency: string = 'USD'): string {
|
||||
return `${currency}${amount.toFixed(2)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse currency string to number
|
||||
*/
|
||||
static parseCurrency(currencyString: string): number {
|
||||
return parseFloat(currencyString.replace(/[^0-9.-]+/g, ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that balances sum to zero (group balance check)
|
||||
*/
|
||||
static validateGroupBalance(balances: number[]): boolean {
|
||||
const sum = balances.reduce((total, balance) => total + balance, 0)
|
||||
return Math.abs(sum) < 0.01 // Allow for small rounding errors
|
||||
}
|
||||
}
|
||||
142
tests/utils/reliability.ts
Normal file
142
tests/utils/reliability.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Page, Locator, expect } from '@playwright/test'
|
||||
|
||||
export class ReliabilityUtils {
|
||||
/**
|
||||
* Wait for element with multiple strategies and retries
|
||||
*/
|
||||
static async waitForElement(
|
||||
page: Page,
|
||||
selectors: string[],
|
||||
options: { timeout?: number; retries?: number } = {}
|
||||
): Promise<Locator> {
|
||||
const { timeout = 10000, retries = 3 } = options
|
||||
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
for (const selector of selectors) {
|
||||
try {
|
||||
const element = page.locator(selector)
|
||||
await element.waitFor({ state: 'visible', timeout: timeout / retries })
|
||||
return element
|
||||
} catch (error) {
|
||||
// Continue to next selector
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt < retries - 1) {
|
||||
// Wait a bit before retry
|
||||
await page.waitForTimeout(1000)
|
||||
await page.waitForLoadState('networkidle')
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`None of the selectors found after ${retries} attempts: ${selectors.join(', ')}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to tab with reliability checks
|
||||
*/
|
||||
static async navigateToTab(page: Page, tabName: string, expectedUrl: RegExp) {
|
||||
// Click tab with retry and verification
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
// Ensure we're in a stable state before clicking
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Click the tab
|
||||
await page.getByTestId(`tab-${tabName}`).click()
|
||||
|
||||
// Wait for the URL to change with a shorter timeout per attempt
|
||||
try {
|
||||
await page.waitForURL(expectedUrl, { timeout: 3000 })
|
||||
// If we get here, navigation succeeded
|
||||
break
|
||||
} catch (urlError) {
|
||||
// URL didn't change, try again
|
||||
if (attempt === 4) {
|
||||
// Last attempt failed, throw the error
|
||||
throw new Error(`Failed to navigate to ${tabName} tab after ${attempt + 1} attempts. Current URL: ${page.url()}`)
|
||||
}
|
||||
|
||||
// Wait a bit before retrying
|
||||
await page.waitForTimeout(1000)
|
||||
continue
|
||||
}
|
||||
} catch (error) {
|
||||
if (attempt === 4) throw error
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
}
|
||||
|
||||
// Final stability checks
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify content loaded with multiple fallback strategies
|
||||
*/
|
||||
static async verifyContentLoaded(page: Page, contentIdentifiers: string[]) {
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (const identifier of contentIdentifiers) {
|
||||
try {
|
||||
if (identifier.startsWith('text=')) {
|
||||
await expect(page.locator(identifier)).toBeVisible({ timeout: 5000 })
|
||||
return
|
||||
} else if (identifier.startsWith('[data-testid=')) {
|
||||
await expect(page.locator(identifier)).toBeVisible({ timeout: 5000 })
|
||||
return
|
||||
} else {
|
||||
await expect(page.getByTestId(identifier)).toBeVisible({ timeout: 5000 })
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error as Error
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error(`No content identifiers found: ${contentIdentifiers.join(', ')}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced page load waiting
|
||||
*/
|
||||
static async waitForStablePage(page: Page) {
|
||||
// Wait for network to be idle
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Wait for any pending JavaScript
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
|
||||
// Additional short wait for dynamic content
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry operation with exponential backoff
|
||||
*/
|
||||
static async retryOperation<T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries: number = 3,
|
||||
baseDelay: number = 1000
|
||||
): Promise<T> {
|
||||
let lastError: Error
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation()
|
||||
} catch (error) {
|
||||
lastError = error as Error
|
||||
|
||||
if (attempt < maxRetries - 1) {
|
||||
const delay = baseDelay * Math.pow(2, attempt)
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError!
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user