feat: establish Playwright E2E testing infrastructure with configuration and initial tests
This commit is contained in:
@@ -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
|
||||||
@@ -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/
|
||||||
|
|||||||
+5
-1
@@ -9,7 +9,11 @@ module.exports = [
|
|||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
'projects/**/*',
|
'projects/**/*',
|
||||||
'dist/**/*'
|
'dist/**/*',
|
||||||
|
'.angular/**/*',
|
||||||
|
'node_modules/**/*',
|
||||||
|
'playwright-report/**/*',
|
||||||
|
'test-results/**/*'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Generated
+1937
-2354
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -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",
|
||||||
|
|||||||
@@ -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'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user