-
Notifications
You must be signed in to change notification settings - Fork 889
Add initial Playwright E2E tests for Client module #3355
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,236 @@ | ||
| /** | ||
| * Copyright since 2025 Mifos Initiative | ||
| * | ||
| * This Source Code Form is subject to the terms of the Mozilla Public | ||
| * License, v. 2.0. If a copy of the MPL was not distributed with this | ||
| * file, You can obtain one at http://mozilla.org/MPL/2.0/. | ||
| */ | ||
|
|
||
| import { Locator, expect } from '@playwright/test'; | ||
| import { BasePage } from './BasePage'; | ||
|
|
||
| /** | ||
| * ClientPage - Page Object for the Mifos X Clients module. | ||
| * | ||
| * Encapsulates all client-related interactions and element locators. | ||
| * Extends BasePage for common functionality. | ||
| * | ||
| * Locator Strategy: | ||
| * - Uses formcontrolname attributes for form inputs | ||
| * - Uses getByRole for buttons | ||
| * - Uses CSS selectors for table elements | ||
| */ | ||
| export class ClientPage extends BasePage { | ||
| // The URL path for the clients list page. | ||
| readonly url = '/#/clients'; | ||
|
|
||
| // Get the search input on the clients list page. | ||
| get searchInput(): Locator { | ||
| return this.page.locator('mat-form-field.search-box input'); | ||
| } | ||
|
|
||
| // Get the "Create Client" button. | ||
| get createClientButton(): Locator { | ||
| return this.page.getByRole('button', { name: /Create Client/i }); | ||
| } | ||
|
|
||
| // Get the "Import Client" button. | ||
| get importClientButton(): Locator { | ||
| return this.page.getByRole('button', { name: /Import Client/i }); | ||
| } | ||
|
|
||
| // Get the client list table element. | ||
| get clientTable(): Locator { | ||
| return this.page.locator('table.bordered-table'); | ||
| } | ||
|
|
||
| // Get all rows in the client list table. | ||
| get clientRows(): Locator { | ||
| return this.page.locator('table.bordered-table tbody tr'); | ||
| } | ||
|
|
||
| // Get the first client name cell (the displayName column carries the routerLink). | ||
| get firstClientNameCell(): Locator { | ||
| return this.page.locator('table.bordered-table tbody tr td.mat-column-displayName').first(); | ||
| } | ||
|
|
||
| // Get the "No client was found" alert message. | ||
| get noClientFoundMessage(): Locator { | ||
| return this.page.locator('.alert .message'); | ||
| } | ||
|
|
||
| // Get the paginator. | ||
| get paginator(): Locator { | ||
| return this.page.locator('mat-paginator'); | ||
| } | ||
|
|
||
| // Get the Office select dropdown. | ||
| get officeSelect(): Locator { | ||
| return this.page.locator('mat-select[formcontrolname="officeId"]'); | ||
| } | ||
|
|
||
| // Get the Legal Form select dropdown. | ||
| get legalFormSelect(): Locator { | ||
| return this.page.locator('mat-select[formcontrolname="legalFormId"]'); | ||
| } | ||
|
|
||
| // Get the First Name input field. | ||
| get firstNameInput(): Locator { | ||
| return this.page.locator('input[formcontrolname="firstname"]'); | ||
| } | ||
|
|
||
| // Get the Middle Name input field. | ||
| get middleNameInput(): Locator { | ||
| return this.page.locator('input[formcontrolname="middlename"]'); | ||
| } | ||
|
|
||
| // Get the Last Name input field. | ||
| get lastNameInput(): Locator { | ||
| return this.page.locator('input[formcontrolname="lastname"]'); | ||
| } | ||
|
|
||
| // Get the External ID input field. | ||
| get externalIdInput(): Locator { | ||
| return this.page.locator('input[formcontrolname="externalId"]'); | ||
| } | ||
|
|
||
| // Get the Submitted On date input field. | ||
| get submittedOnDateInput(): Locator { | ||
| return this.page.locator('input[formcontrolname="submittedOnDate"]'); | ||
| } | ||
|
|
||
| // Get the "Performance History" heading on the client detail page. | ||
| get performanceHistoryHeading(): Locator { | ||
| return this.page.getByRole('heading', { name: /Performance History/i }); | ||
| } | ||
|
|
||
| // Get the "Loan Accounts" heading on the client detail page. | ||
| get loanAccountsHeading(): Locator { | ||
| return this.page.getByRole('heading', { name: /Loan Accounts/i }); | ||
| } | ||
|
|
||
| // Get the "Saving Accounts" heading on the client detail page. | ||
| get savingAccountsHeading(): Locator { | ||
| return this.page.getByRole('heading', { name: /Saving Accounts/i }); | ||
| } | ||
|
|
||
| // Navigate to the Clients list page. | ||
| async navigateToClients(): Promise<void> { | ||
| await this.page.goto(this.url, { | ||
| waitUntil: 'load', | ||
| timeout: 60000 | ||
| }); | ||
| await this.waitForLoad(); | ||
| } | ||
|
|
||
| /** | ||
| * Wait for the clients page to be fully loaded. | ||
| * Overrides BasePage to wait for page-specific elements. | ||
| */ | ||
| async waitForLoad(): Promise<void> { | ||
| await this.page.waitForLoadState('domcontentloaded'); | ||
| try { | ||
| await this.page.waitForLoadState('networkidle', { timeout: 15000 }); | ||
| } catch (e) { | ||
| console.log('Network idle timeout on clients page - proceeding anyway'); | ||
| } | ||
| // Verify the UI is ready by checking for the search input | ||
| await this.searchInput.waitFor({ state: 'visible', timeout: 15000 }); | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /** | ||
| * Search for a client by name using the search input. | ||
| * @param name - The client name to search for | ||
| */ | ||
| async searchClient(name: string): Promise<void> { | ||
| await this.searchInput.click(); | ||
| await this.searchInput.fill(name); | ||
| await this.searchInput.press('Enter'); | ||
| try { | ||
| await this.page.waitForLoadState('networkidle', { timeout: 10000 }); | ||
| } catch (e) { | ||
| console.log('Network idle timeout after search - proceeding anyway'); | ||
| } | ||
| // Wait for observable results: either rows appear or "no client found" message | ||
| await Promise.race([ | ||
| this.clientRows.first().waitFor({ state: 'visible', timeout: 15000 }), | ||
| this.noClientFoundMessage.waitFor({ state: 'visible', timeout: 15000 }) | ||
| ]); | ||
|
Comment on lines
+145
to
+158
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🔧 Suggested fix async searchClient(name: string): Promise<void> {
await this.searchInput.click();
await this.searchInput.fill(name);
await this.searchInput.press('Enter');
try {
await this.page.waitForLoadState('networkidle', { timeout: 10000 });
} catch (e) {
console.log('Network idle timeout after search - proceeding anyway');
}
- // Wait for observable results: either rows appear or "no client found" message
+ const matchingClientNameCell = this.page
+ .locator('table.bordered-table tbody tr td.mat-column-displayName')
+ .filter({ hasText: name })
+ .first();
+
+ // Wait for concrete search outcome: matching row or empty-state message
await Promise.race([
- this.clientRows.first().waitFor({ state: 'visible', timeout: 15000 }),
- this.noClientFoundMessage.waitFor({ state: 'visible', timeout: 15000 })
+ matchingClientNameCell.waitFor({ state: 'visible', timeout: 15000 }),
+ this.page.getByText(/No client was found/i).waitFor({ state: 'visible', timeout: 15000 })
]);
}🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /** | ||
| * Click the "Create Client" button and wait for navigation to the form. | ||
| */ | ||
| async openCreateClientForm(): Promise<void> { | ||
| await this.createClientButton.click(); | ||
| await this.page.waitForURL(/.*clients\/create.*/, { | ||
| timeout: 30000, | ||
| waitUntil: 'networkidle' | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Fill the client creation form with first name and last name. | ||
| * | ||
| * @param firstName - The first name to enter | ||
| * @param lastName - The last name to enter | ||
| */ | ||
| async fillClientForm(firstName: string, lastName: string): Promise<void> { | ||
| await this.firstNameInput.waitFor({ state: 'visible', timeout: 10000 }); | ||
| await this.firstNameInput.clear(); | ||
| await this.firstNameInput.fill(firstName); | ||
|
|
||
| await this.lastNameInput.waitFor({ state: 'visible', timeout: 10000 }); | ||
| await this.lastNameInput.clear(); | ||
| await this.lastNameInput.fill(lastName); | ||
| } | ||
|
|
||
| /** | ||
| * Click the "Next" button on the general step of the client creation stepper. | ||
| */ | ||
| async submitClientForm(): Promise<void> { | ||
| const nextButton = this.page.getByRole('button', { name: /Next/i }); | ||
| await nextButton.click(); | ||
| } | ||
|
|
||
| /** | ||
| * Click the first client name cell to open the client detail page. | ||
| * The routerLink is on the name td, not the tr row. | ||
| */ | ||
| async openClientProfile(): Promise<void> { | ||
| await this.firstClientNameCell.waitFor({ state: 'visible', timeout: 15000 }); | ||
| await this.firstClientNameCell.click(); | ||
|
|
||
| // Avoid networkidle — the detail page fires many concurrent API calls | ||
| await this.page.waitForURL(/.*clients\/\d+\/general.*/, { | ||
| timeout: 30000 | ||
| }); | ||
| await this.performanceHistoryHeading.waitFor({ | ||
| state: 'visible', | ||
| timeout: 30000 | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Assert that the client list page is visible. | ||
| */ | ||
| async assertClientListVisible(): Promise<void> { | ||
| await expect(this.searchInput).toBeVisible({ timeout: 15000 }); | ||
| } | ||
|
|
||
| /** | ||
| * Assert that the create client form is visible. | ||
| */ | ||
| async assertCreateFormVisible(): Promise<void> { | ||
| await expect(this.officeSelect).toBeVisible({ timeout: 15000 }); | ||
| await expect(this.legalFormSelect).toBeVisible({ timeout: 15000 }); | ||
| } | ||
|
|
||
| /** | ||
| * Assert that the client detail page is loaded. | ||
| */ | ||
| async assertClientDetailVisible(): Promise<void> { | ||
| await expect(this.performanceHistoryHeading).toBeVisible({ timeout: 15000 }); | ||
| await expect(this.page).toHaveURL(/.*clients\/\d+\/general.*/); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| /** | ||
| * Copyright since 2025 Mifos Initiative | ||
| * | ||
| * This Source Code Form is subject to the terms of the Mozilla Public | ||
| * License, v. 2.0. If a copy of the MPL was not distributed with this | ||
| * file, You can obtain one at http://mozilla.org/MPL/2.0/. | ||
| */ | ||
|
|
||
| import { test, expect } from '@playwright/test'; | ||
| import { LoginPage } from '../pages/login.page'; | ||
| import { ClientPage } from '../pages/client.page'; | ||
|
|
||
| /** | ||
| * Client Module E2E Tests | ||
| * | ||
| * Validates the core client management functionality of the Mifos X Web App. | ||
| * | ||
| * Prerequisites: | ||
| * - Angular dev server running on http://localhost:4200 | ||
| * - Fineract backend accessible (via proxy to https://localhost:8443) | ||
| * | ||
| * Test Data: | ||
| * - Valid credentials: mifos / password | ||
| */ | ||
|
|
||
| // Skip in CI - requires Fineract backend | ||
| test.skip(!!process.env.CI, 'Requires Fineract backend'); | ||
|
|
||
| test.describe('Client Module', () => { | ||
| let loginPage: LoginPage; | ||
| let clientPage: ClientPage; | ||
|
|
||
| test.beforeEach(async ({ page }) => { | ||
| loginPage = new LoginPage(page); | ||
| clientPage = new ClientPage(page); | ||
|
|
||
| await loginPage.navigate(); | ||
| await loginPage.loginAndWaitForDashboard('mifos', 'password'); | ||
| }); | ||
|
|
||
| test('should display the client list', async () => { | ||
| await clientPage.navigateToClients(); | ||
|
|
||
| await clientPage.assertClientListVisible(); | ||
| await expect(clientPage.createClientButton).toBeVisible(); | ||
| }); | ||
|
|
||
| test('should open the create client form', async ({ page }) => { | ||
| await clientPage.navigateToClients(); | ||
| await clientPage.openCreateClientForm(); | ||
|
|
||
| await clientPage.assertCreateFormVisible(); | ||
| await expect(page).toHaveURL(/.*clients\/create.*/); | ||
| }); | ||
|
|
||
| test('should open a client profile', async () => { | ||
| await clientPage.navigateToClients(); | ||
|
|
||
| // Trigger search to populate the table | ||
| await clientPage.searchClient(''); | ||
|
|
||
| await expect(clientPage.clientRows.first()).toBeVisible({ timeout: 30000 }); | ||
| await clientPage.openClientProfile(); | ||
| await clientPage.assertClientDetailVisible(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: openMF/web-app
Length of output: 1610
🏁 Script executed:
# Check the imports at the top of the file head -20 playwright/pages/client.page.tsRepository: openMF/web-app
Length of output: 696
🌐 Web query:
Playwright test errors.TimeoutError how to catch timeout errors💡 Result:
To catch Playwright timeout errors, you need to distinguish operation timeouts (catchable) from test runner timeouts (not really catchable).
1) Catch operation timeouts (click / waitFor / goto / assertions, etc.)
These throw
playwright.errors.TimeoutError, which you can catch withtry/catchandinstanceof:This is the pattern shown in the official
TimeoutErrordocs. [1]2) “Test timeout of 30000ms exceeded” is a test-level timeout
If the entire test hits the Playwright Test timeout (default 30s), the runner fails the test; you generally should increase/adjust the test timeout instead of trying to catch it:
test.setTimeout(120_000)(per-test)timeout: 120_000(inplaywright.config.ts)Playwright documents these test/expect/action timeout layers separately. [2]
Sources:
[1] Playwright API:
TimeoutError(andinstanceof playwright.errors.TimeoutErrorexample) (playwright.dev)[2] Playwright Test: Timeouts (test timeout vs expect/action/navigation timeouts) (playwright.dev)
Citations:
🏁 Script executed:
Repository: openMF/web-app
Length of output: 1775
🏁 Script executed:
Repository: openMF/web-app
Length of output: 40
🏁 Script executed:
Repository: openMF/web-app
Length of output: 2853
🌐 Web query:
@playwright/test errors.TimeoutError import💡 Result:
TimeoutErroris not exported from@playwright/test, soimport { errors.TimeoutError } from '@playwright/test'won’t work.Use one of these instead:
1) In Playwright Test (recommended): use the built-in
playwrightfixtureThis is the pattern shown in the official docs (
playwright.errors.TimeoutError). [1]2) Import from the Playwright library package (not
@playwright/test)CommonJS
[1]
If you paste the exact import line and the error message you’re getting, I can tell you which of the two fits your setup (ESM vs CJS, TS config, etc.).
Sources: [1]
🏁 Script executed:
Repository: openMF/web-app
Length of output: 1101
🏁 Script executed:
Repository: openMF/web-app
Length of output: 40
🏁 Script executed:
Repository: openMF/web-app
Length of output: 1375
🏁 Script executed:
Repository: openMF/web-app
Length of output: 94
🏁 Script executed:
Repository: openMF/web-app
Length of output: 40
🌐 Web query:
Playwright page object error handling TimeoutError import from playwright package💡 Result:
JavaScript / TypeScript (Playwright “library” or
@playwright/test)TimeoutErrorisn’t imported as a top-level named export. Catch it viaplaywright.errors.TimeoutError(orerrors.TimeoutError). (playwright.dev)Alternative:
Python (if that’s what you meant)
(Then
except TimeoutError:.) (stackoverflow.com)Citations:
Catch only timeout errors; rethrow other Playwright failures.
Lines 134–135 and 151–152 catch all exceptions and proceed, which masks real failures (page closed, navigation aborted, context errors, etc.). Only swallow
TimeoutErrorand rethrow everything else.Suggested fix
🤖 Prompt for AI Agents