mirror of
https://github.com/spliit-app/spliit.git
synced 2026-02-17 21:16:14 +01:00
Complete E2E test implementation with comprehensive reliability fixes
- Implement Priority 1 E2E test coverage for critical user journeys: * Group lifecycle management (creation, navigation, editing) * Basic expense management (create, view, edit, delete) * Balance calculation and verification * Multiple expense scenarios and split calculations - Add comprehensive reliability infrastructure: * ReliabilityUtils class with retry mechanisms and enhanced navigation * Page Object Model (POM) architecture for maintainable tests * Test data management utilities with unique identifiers * Enhanced Playwright configuration with increased timeouts and retries - Fix all flaky test issues: * Add required test IDs to UI components for reliable element targeting * Implement multiple fallback strategies for element selection * Enhanced tab navigation with URL verification and retry logic * Proper wait strategies for network idle states and dynamic content - Test results: 39/39 tests passing (100% success rate) across all browsers 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
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