feat: establish Playwright E2E testing infrastructure with configuration and initial tests

This commit is contained in:
Devansh Vashisht
2025-12-24 04:57:28 +05:30
parent 978a62a6b3
commit 1d2a23b5b6
13 changed files with 2730 additions and 2357 deletions
+53
View File
@@ -0,0 +1,53 @@
name: Playwright E2E Tests
on:
push:
branches: [main, dev, dev-angular-19]
pull_request:
branches: [main, dev, dev-angular-19]
jobs:
playwright:
name: Run Playwright Tests
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Use Node.js 22x
uses: actions/setup-node@v4
with:
node-version: '22.12.0'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build application
run: npm run build
- name: Run Playwright tests
run: npx playwright test --reporter=html,github
env:
CI: true
- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results
path: test-results/
retention-days: 7
+6
View File
@@ -63,3 +63,9 @@ cypress/videos/
src/assets/env.js src/assets/env.js
src/environments/.env.ts src/environments/.env.ts
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
+1
View File
@@ -0,0 +1 @@
22.12.0
+5 -1
View File
@@ -9,7 +9,11 @@ module.exports = [
{ {
ignores: [ ignores: [
'projects/**/*', 'projects/**/*',
'dist/**/*' 'dist/**/*',
'.angular/**/*',
'node_modules/**/*',
'playwright-report/**/*',
'test-results/**/*'
] ]
}, },
+1937 -2354
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -28,7 +28,11 @@
"env": "node version.js", "env": "node version.js",
"postinstall": "node version.js", "postinstall": "node version.js",
"cypress:open": "cypress open", "cypress:open": "cypress open",
"cypress:run": "cypress run" "cypress:run": "cypress run",
"playwright": "playwright test",
"playwright:ui": "playwright test --ui",
"playwright:headed": "playwright test --headed",
"playwright:debug": "playwright test --debug"
}, },
"dependencies": { "dependencies": {
"@angular/animations": "20.3.15", "@angular/animations": "20.3.15",
@@ -81,6 +85,7 @@
"@angular/compiler-cli": "20.3.15", "@angular/compiler-cli": "20.3.15",
"@angular/language-service": "20.3.15", "@angular/language-service": "20.3.15",
"@cypress/schematic": "^2.5.2", "@cypress/schematic": "^2.5.2",
"@playwright/test": "^1.57.0",
"@types/d3-dispatch": "^3.0.1", "@types/d3-dispatch": "^3.0.1",
"@types/d3-drag": "^3.0.1", "@types/d3-drag": "^3.0.1",
"@types/d3-scale": "^3.2.2", "@types/d3-scale": "^3.2.2",
+94
View File
@@ -0,0 +1,94 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright configuration for Mifos X Web App E2E tests.
*
* Optimized for:
* - Self-signed SSL certificate handling (local Fineract backend)
* - CI/CD efficiency (artifacts only on failure)
* - Debugging support (traces, videos, screenshots)
*
* @see https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
// Test directory
testDir: './playwright/tests',
// Exclude Angular component tests
testIgnore: 'src/**',
// Fail the build if test.only is left in source (CI safety)
forbidOnly: !!process.env.CI,
// Retry failed tests in CI
retries: process.env.CI ? 2 : 0,
// Limit parallel workers in CI
workers: process.env.CI ? 1 : undefined,
// Reporter configuration
reporter: [
[
'html',
{ outputFolder: 'playwright-report', open: 'never' }],
['list']
],
// Global test settings
use: {
// Base URL for the Angular app
baseURL: 'http://localhost:4200',
// Handle self-signed certificates from Fineract backend
ignoreHTTPSErrors: true,
// Collect trace on failure for debugging
trace: 'retain-on-failure',
// Record video on failure only (storage efficient)
video: 'retain-on-failure',
// Screenshot on failure only
screenshot: 'only-on-failure',
// Default navigation timeout (increased for CI and financial app)
navigationTimeout: 60000,
// Default action timeout
actionTimeout: 20000
},
// Global test timeout (per test)
timeout: process.env.CI ? 90000 : 60000,
// Configure projects for different browsers
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
// Launch options for handling SSL in headed mode
launchOptions: {
args: ['--ignore-certificate-errors']
}
}
}
],
// Web server configuration
// In CI: builds production bundle (without base-href), then serves it with http-server
// Locally: reuses existing ng serve if running
webServer: {
command: process.env.CI
? 'npm run build && npx http-server ./dist/web-app/browser -p 4200 --silent'
: 'npm run start',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
timeout: 180000,
// Add retry logic for server startup
...(process.env.CI && {
stdout: 'pipe',
stderr: 'pipe'
})
}
});
+147
View File
@@ -0,0 +1,147 @@
# Playwright E2E Testing
Production-grade End-to-End testing infrastructure for Mifos X Web App using [Playwright](https://playwright.dev/).
## Prerequisites
Before running the tests, ensure the local environment is running:
1. **Backend:** Ensure Fineract is running on `https://localhost:8443`
2. **Frontend:** Serve the Angular app:
```bash
ng serve
```
## Quick Start
```bash
# Run all tests
npm run playwright
# Run with UI mode (recommended for debugging)
npm run playwright:ui
# Run with visible browser
npm run playwright:headed
# Debug mode (step through tests)
npm run playwright:debug
```
## Architecture
This framework follows the **Page Object Model (POM)** pattern:
```
playwright/
├── pages/
│ ├── BasePage.ts # Abstract base class with common utilities
│ └── LoginPage.ts # Login page object
└── tests/
└── login.spec.ts # Login smoke tests
```
### Design Principles
| Principle | Implementation |
| -------------------------- | --------------------------------------------- |
| **Separation of Concerns** | Locators in Page Objects, assertions in Tests |
| **DRY** | Common actions in `BasePage` |
| **Maintainability** | Centralized locators per page |
| **CI/CD Optimization** | Artifacts only on failure |
## Configuration
### SSL Certificate Handling
The local Fineract backend uses a self-signed certificate. This is handled automatically:
```typescript
// playwright.config.ts
ignoreHTTPSErrors: true;
```
### TypeScript Isolation
Uses a dedicated `tsconfig.playwright.json` to avoid conflicts with Jest:
```json
{
"types": ["node"] // Excludes Jest types
}
```
## Writing New Tests
### 1. Create a Page Object
```typescript
// playwright/pages/DashboardPage.ts
import { BasePage } from './BasePage';
export class DashboardPage extends BasePage {
readonly url = '/#/';
get welcomeMessage() {
return this.page.locator('.welcome-message');
}
async assertOnDashboard(): Promise<void> {
await expect(this.page).toHaveURL(/.*\//);
}
}
```
### 2. Write the Test
```typescript
// playwright/tests/dashboard.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
test('should display dashboard after login', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);
await loginPage.navigate();
await loginPage.loginAndWaitForDashboard('mifos', 'password');
await dashboardPage.assertOnDashboard();
});
```
## CI/CD
Tests run automatically on push/PR via GitHub Actions:
```bash
.github/workflows/playwright.yml
```
### Viewing Test Reports
After CI runs, download the `playwright-report` artifact from the Actions tab.
## Test Credentials
| Username | Password | Environment |
| -------- | ---------- | ------------- |
| `mifos` | `password` | Local/Sandbox |
## Troubleshooting
### Tests fail with SSL errors
Ensure `ignoreHTTPSErrors: true` is set in `playwright.config.ts`.
### Tests timeout on CI
Increase the timeout in `playwright.config.ts`:
```typescript
navigationTimeout: 60000,
```
### TypeScript conflicts with Jest
Ensure tests are only in `playwright/` directory and `tsconfig.playwright.json` excludes other directories.
+136
View File
@@ -0,0 +1,136 @@
import { Page, Locator } from '@playwright/test';
/**
* BasePage - Abstract base class for Page Object Model.
*
* Provides common functionality for all page objects:
* - Navigation helpers with wait states
* - Reusable interaction methods
* - Screenshot utilities
*
* All page objects should extend this class.
*/
export abstract class BasePage {
/**
* The Playwright Page instance.
* Protected to allow access in child classes.
*/
protected readonly page: Page;
/**
* The URL path for this page (relative to baseURL).
* Must be implemented by child classes.
*/
abstract readonly url: string;
/**
* Creates a new BasePage instance.
* @param page - The Playwright Page instance
*/
constructor(page: Page) {
this.page = page;
}
/**
* Navigate to this page's URL.
* Uses the route defined in the `url` property.
*/
async navigate(): Promise<void> {
// Navigate with extended timeout and wait until load event
await this.page.goto(this.url, {
waitUntil: 'load',
timeout: 60000
});
await this.waitForLoad();
}
/**
* Wait for the page to be fully loaded.
* Override in child classes for page-specific load conditions.
*/
async waitForLoad(): Promise<void> {
await this.page.waitForLoadState('networkidle');
}
/**
* Get the page title.
* @returns The document title
*/
async getTitle(): Promise<string> {
return this.page.title();
}
/**
* Get the current URL.
* @returns The current page URL
*/
getCurrentUrl(): string {
return this.page.url();
}
/**
* Wait for an element to be visible.
* @param locator - The Playwright Locator
* @param timeout - Optional timeout in milliseconds
*/
async waitForVisible(locator: Locator, timeout?: number): Promise<void> {
await locator.waitFor({ state: 'visible', timeout });
}
/**
* Wait for an element to be hidden.
* @param locator - The Playwright Locator
* @param timeout - Optional timeout in milliseconds
*/
async waitForHidden(locator: Locator, timeout?: number): Promise<void> {
await locator.waitFor({ state: 'hidden', timeout });
}
/**
* Click an element and wait for navigation.
* Useful for buttons that trigger page redirects.
* @param locator - The Playwright Locator to click
*/
async clickAndWaitForNavigation(locator: Locator): Promise<void> {
await Promise.all([
this.page.waitForURL(/.*/, { waitUntil: 'networkidle' }),
locator.click()
]);
}
/**
* Fill a form field after clearing existing content.
* @param locator - The Playwright Locator for the input
* @param value - The value to fill
*/
async fillField(locator: Locator, value: string): Promise<void> {
await locator.clear();
await locator.fill(value);
}
/**
* Take a screenshot of the current page.
* @param name - Name for the screenshot file
*/
async takeScreenshot(name: string): Promise<void> {
await this.page.screenshot({ path: `playwright-report/${name}.png` });
}
/**
* Check if an element is visible.
* @param locator - The Playwright Locator
* @returns true if visible, false otherwise
*/
async isVisible(locator: Locator): Promise<boolean> {
return locator.isVisible();
}
/**
* Get text content from an element.
* @param locator - The Playwright Locator
* @returns The text content or null
*/
async getText(locator: Locator): Promise<string | null> {
return locator.textContent();
}
}
+189
View File
@@ -0,0 +1,189 @@
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './BasePage';
/**
* LoginPage - Page Object for the Mifos X Login page.
*
* Encapsulates all login-related interactions and element locators.
* Extends BasePage for common functionality.
*
* Locator Strategy:
* - Uses exact selectors from Playwright codegen for maximum stability
* - Preserves complex selectors (filter, nth) as generated by codegen
* - These selectors match the actual DOM structure of the Angular Material UI
*/
export class LoginPage extends BasePage {
/**
* The URL path for the login page.
*/
readonly url = '/#/login';
/**
* Centralized locators for all page elements.
* Using exact selectors from Playwright codegen.
*/
/**
* Get the username input wrapper div.
* Uses codegen selector: div.filter({ hasText: 'Username' }).nth(5)
*/
get usernameDivWrapper(): Locator {
return this.page.locator('div').filter({ hasText: 'Username' }).nth(5);
}
/**
* Get the username input field.
* Uses codegen selector for the actual input.
*/
get usernameInput(): Locator {
return this.page.getByRole('textbox', { name: 'Username' });
}
/**
* Get the password input wrapper div.
* Uses codegen selector: div.filter({ hasText: 'Password' }).nth(5)
*/
get passwordDivWrapper(): Locator {
return this.page.locator('div').filter({ hasText: 'Password' }).nth(5);
}
/**
* Get the password input field.
* Uses codegen selector for the actual input.
*/
get passwordInput(): Locator {
return this.page.getByRole('textbox', { name: 'Password' });
}
/**
* Get the login button.
* Uses codegen selector: getByRole('button', { name: 'Login' })
*/
get loginButton(): Locator {
return this.page.getByRole('button', { name: 'Login' });
}
// Additional helper locators for validation and assertions
private readonly locators = {
errorMessage: 'mat-error',
progressBar: 'mat-progress-bar',
loginForm: '#login-form'
};
/**
* Get all error messages on the page.
*/
get errorMessages(): Locator {
return this.page.locator(this.locators.errorMessage);
}
/**
* Get the progress bar (loading indicator).
*/
get progressBar(): Locator {
return this.page.locator(this.locators.progressBar);
}
/**
* Wait for the login page to be fully loaded.
* Overrides BasePage to wait for login form visibility.
*/
async waitForLoad(): Promise<void> {
// Wait for basic page load
await this.page.waitForLoadState('domcontentloaded');
// Try to wait for network idle, but don't fail if it takes too long
try {
await this.page.waitForLoadState('networkidle', { timeout: 10000 });
} catch (e) {
console.log('Network idle timeout - proceeding anyway');
}
// Wait for the login button to be visible with extended timeout
await this.loginButton.waitFor({ state: 'visible', timeout: 60000 });
}
/**
* Perform login with the given credentials.
* Uses the exact interaction sequence from codegen.
*
* @param username - The username to enter
* @param password - The password to enter
*/
async login(username: string, password: string): Promise<void> {
// Click the username wrapper div (as per codegen)
await this.usernameDivWrapper.click();
// Fill the username input
await this.usernameInput.fill(username);
// Click the password wrapper div (as per codegen)
await this.passwordDivWrapper.click();
// Fill the password input
await this.passwordInput.fill(password);
// Click the login button
await this.loginButton.click();
}
/**
* Perform login and wait for successful navigation.
* Use this when expecting a successful login.
*
* @param username - The username to enter
* @param password - The password to enter
*/
async loginAndWaitForDashboard(username: string, password: string): Promise<void> {
await this.login(username, password);
// Wait for navigation away from login page
await this.page.waitForURL(/.*(?<!login)$/, {
timeout: 30000,
waitUntil: 'networkidle'
});
}
/**
* Check if the login button is enabled.
* @returns true if the login button is enabled
*/
async isLoginButtonEnabled(): Promise<boolean> {
return this.loginButton.isEnabled();
}
/**
* Get the count of validation errors displayed.
* @returns Number of visible error messages
*/
async getErrorCount(): Promise<number> {
return this.errorMessages.count();
}
/**
* Check if page is showing a loading state.
* @returns true if progress bar is visible
*/
async isLoading(): Promise<boolean> {
return this.progressBar.isVisible();
}
/**
* Assert that we are on the login page.
*/
async assertOnLoginPage(): Promise<void> {
await expect(this.page).toHaveURL(/.*login/);
await expect(this.loginButton).toBeVisible();
}
/**
* Assert successful login by checking we're no longer on login page.
*/
async assertLoginSuccess(): Promise<void> {
await expect(this.page).not.toHaveURL(/.*login/, { timeout: 30000 });
}
/**
* Assert that a validation error is displayed.
*/
async assertValidationError(): Promise<void> {
await expect(this.errorMessages.first()).toBeVisible();
}
}
+129
View File
@@ -0,0 +1,129 @@
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
/**
* Login Smoke Tests
*
* This test suite validates the core login functionality of the Mifos X Web App.
* These tests use a strict Page Object Model with selectors from Playwright codegen.
*
* Prerequisites:
* - Angular dev server running on http://localhost:4200
* - Fineract backend accessible (via proxy to https://localhost:8443)
*
* Test Data:
* - Valid credentials: mifos / password
*/
test.describe('Login Page', () => {
let loginPage: LoginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.navigate();
});
test('should display the login form', async () => {
// Verify the login form is visible
await loginPage.assertOnLoginPage();
// Verify form elements are present (using codegen selectors)
await expect(loginPage.usernameInput).toBeVisible();
await expect(loginPage.passwordInput).toBeVisible();
await expect(loginPage.loginButton).toBeVisible();
});
test('should disable login button when form is empty', async () => {
// Login button should be disabled with empty fields
await expect(loginPage.loginButton).toBeDisabled();
});
test('should show validation errors for required fields', async ({ page }) => {
// Focus and blur username field to trigger validation
await loginPage.usernameInput.focus();
await loginPage.passwordInput.focus();
await page.keyboard.press('Tab');
// Check for validation error messages
const hasErrors = await loginPage.getErrorCount();
expect(hasErrors).toBeGreaterThan(0);
});
test('should enable login button when credentials are entered', async () => {
// Fill in credentials using the wrapper clicks (as per codegen)
await loginPage.usernameDivWrapper.click();
await loginPage.usernameInput.fill('mifos');
await loginPage.passwordDivWrapper.click();
await loginPage.passwordInput.fill('password');
// Login button should become enabled
await expect(loginPage.loginButton).toBeEnabled();
});
// Skip in CI - requires Fineract backend
test.skip(!!process.env.CI, 'Requires Fineract backend');
test('should successfully login with valid credentials', async () => {
// Perform login with valid credentials
// This uses the login() method which follows the exact codegen sequence
await loginPage.loginAndWaitForDashboard('mifos', 'password');
// Assert we're no longer on the login page
await loginPage.assertLoginSuccess();
});
test('should handle invalid credentials gracefully', async ({ page }) => {
// Attempt login with wrong password
await loginPage.login('mifos', 'wrongpassword');
// Wait for the login attempt to process and any error notification to appear
// The app shows a snackbar notification for authentication errors
await page.waitForTimeout(3000);
// Should remain on login page after failed attempt (URL still contains /login)
await expect(page).toHaveURL(/.*login.*/);
// Verify we're still on the login page by checking form elements are visible
await expect(loginPage.usernameInput).toBeVisible();
await expect(loginPage.passwordInput).toBeVisible();
});
/**
* Simple test that exactly mirrors the codegen script.
* This is the baseline test generated from codegen.
*/
// Skip in CI - requires Fineract backend
test.skip(!!process.env.CI, 'Requires Fineract backend');
test('codegen baseline: login with mifos credentials', async () => {
// This test uses the exact codegen interaction sequence
await loginPage.login('mifos', 'password');
// Verify successful login
await loginPage.assertLoginSuccess();
});
});
/**
* Accessibility smoke test
*/
test.describe('Login Page Accessibility', () => {
let loginPage: LoginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.navigate();
});
test('should have proper page title', async ({ page }) => {
const title = await page.title();
expect(title).toBeTruthy();
});
test('should support keyboard navigation', async ({ page }) => {
// Tab through form elements
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
// Form should be navigable via keyboard
await expect(loginPage.loginButton).toBeVisible();
});
});
+1 -1
View File
@@ -13,7 +13,7 @@
window['env']['fineractPlatformTenantIds'] = '$FINERACT_PLATFORM_TENANTS_IDENTIFIER'; window['env']['fineractPlatformTenantIds'] = '$FINERACT_PLATFORM_TENANTS_IDENTIFIER';
window['env']['tenantLogoUrl'] = '$TENANT_LOGO_URL'; window['env']['tenantLogoUrl'] = '$TENANT_LOGO_URL';
// Language Environment variables // Language Environment variables
window['env']['defaultLanguage'] = '$MIFOS_DEFAULT_LANGUAGE'; window['env']['defaultLanguage'] = '$MIFOS_DEFAULT_LANGUAGE';
window['env']['supportedLanguages'] = '$MIFOS_SUPPORTED_LANGUAGES'; window['env']['supportedLanguages'] = '$MIFOS_SUPPORTED_LANGUAGES';
+26
View File
@@ -0,0 +1,26 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"types": [
"node"
],
"outDir": "./dist-playwright",
"rootDir": "."
},
"include": [
"playwright/**/*.ts",
"playwright.config.ts"
],
"exclude": [
"node_modules",
"src",
"e2e",
"cypress"
]
}