Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 236 additions & 0 deletions playwright/pages/client.page.ts
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');
}
Comment on lines +132 to +136
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's see the actual file content at the specified lines
cat -n playwright/pages/client.page.ts | sed -n '125,160p'

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.ts

Repository: 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 with try/catch and instanceof:

import playwright from 'playwright';

try {
  await page.getByText('Foo').click({ timeout: 1000 });
} catch (e) {
  if (e instanceof playwright.errors.TimeoutError) {
    // handle timeout (fallback, retry, log, etc.)
    return;
  }
  throw e; // not a timeout, rethrow
}

This is the pattern shown in the official TimeoutError docs. [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 (in playwright.config.ts)
    Playwright documents these test/expect/action timeout layers separately. [2]

Sources:
[1] Playwright API: TimeoutError (and instanceof playwright.errors.TimeoutError example) (playwright.dev)
[2] Playwright Test: Timeouts (test timeout vs expect/action/navigation timeouts) (playwright.dev)

Citations:


🏁 Script executed:

# Check if errors is used elsewhere in the codebase with `@playwright/test`
rg "from '@playwright/test'" -A 3 playwright/

Repository: openMF/web-app

Length of output: 1775


🏁 Script executed:

# Look for TimeoutError usage pattern in existing code
rg "TimeoutError|errors\." playwright/ -B 2 -A 2

Repository: openMF/web-app

Length of output: 40


🏁 Script executed:

# Check playwright.config.ts to understand the test setup
cat playwright.config.ts 2>/dev/null || echo "File not found"

Repository: openMF/web-app

Length of output: 2853


🌐 Web query:

@playwright/test errors.TimeoutError import

💡 Result:

TimeoutError is not exported from @playwright/test, so import { errors.TimeoutError } from '@playwright/test' won’t work.

Use one of these instead:

1) In Playwright Test (recommended): use the built-in playwright fixture

import { test } from '@playwright/test';

test('handles timeout', async ({ page, playwright }) => {
  try {
    await page.locator('text=Foo').click({ timeout: 100 });
  } catch (e) {
    if (e instanceof playwright.errors.TimeoutError) {
      // handle timeout
    }
  }
});

This is the pattern shown in the official docs (playwright.errors.TimeoutError). [1]

2) Import from the Playwright library package (not @playwright/test)

CommonJS

const playwright = require('playwright');

if (e instanceof playwright.errors.TimeoutError) { /* ... */ }

[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:

# Check package.json for playwright dependency
cat package.json | grep -A 5 -B 5 "playwright"

Repository: openMF/web-app

Length of output: 1101


🏁 Script executed:

# Check if there are other error handling patterns in the codebase
rg "instanceof.*Error" playwright/ -B 2 -A 2

Repository: openMF/web-app

Length of output: 40


🏁 Script executed:

# Look at BasePage to understand the inheritance pattern
cat -n playwright/pages/BasePage.ts | head -40

Repository: openMF/web-app

Length of output: 1375


🏁 Script executed:

# Check if playwright package (not `@playwright/test`) is available
cat package.json | grep -i "\"playwright\""

Repository: openMF/web-app

Length of output: 94


🏁 Script executed:

# Search for any playwright import from the main package (not `@playwright/test`)
rg "from 'playwright'" . --type ts

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)

TimeoutError isn’t imported as a top-level named export. Catch it via playwright.errors.TimeoutError (or errors.TimeoutError). (playwright.dev)

import * as playwright from 'playwright'; // or: const playwright = require('playwright');

try {
  await page.locator('text=Foo').click({ timeout: 100 });
} catch (e) {
  if (e instanceof playwright.errors.TimeoutError) {
    // handle timeout
  } else {
    throw e;
  }
}

Alternative:

import { errors } from 'playwright';

try { /* ... */ }
catch (e) {
  if (e instanceof errors.TimeoutError) { /* ... */ }
}

Python (if that’s what you meant)

from playwright.sync_api import TimeoutError
# (or from playwright.async_api import TimeoutError)

(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 TimeoutError and rethrow everything else.

Suggested fix
+import { errors } from 'playwright';
 import { Locator, expect } from '@playwright/test';

@@
     } catch (e) {
+      if (!(e instanceof errors.TimeoutError)) throw e;
       console.log('Network idle timeout on clients page - proceeding anyway');
     }

@@
     } catch (e) {
+      if (!(e instanceof errors.TimeoutError)) throw e;
       console.log('Network idle timeout after search - proceeding anyway');
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@playwright/pages/client.page.ts` around lines 132 - 136, The try/catch around
this.page.waitForLoadState currently swallows all exceptions; change it to only
suppress Playwright timeout errors and rethrow everything else: import or
reference Playwright's TimeoutError (e.g., playwright.errors.TimeoutError or
TimeoutError) and in the catch check if the thrown error is an instance of (or
has name === 'TimeoutError') before logging and continuing; if not, rethrow the
error. Apply the same change to the other catch block around the same
waitForLoadState call so only TimeoutError is swallowed and other Playwright
failures propagate.

// Verify the UI is ready by checking for the search input
await this.searchInput.waitFor({ state: 'visible', timeout: 15000 });
}

/**
* 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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

searchClient() can return before filtered results are actually ready.

clientRows.first().waitFor({ state: 'visible' }) may already be true from the pre-search table state, so Promise.race can resolve too early and downstream clicks can target stale rows.

🔧 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
Verify each finding against the current code and only fix it if needed.

In `@playwright/pages/client.page.ts` around lines 145 - 158, searchClient
currently races against clientRows.first().waitFor which can be satisfied by
pre-search rows; capture the pre-search state and await a real change: before
filling searchInput read the current count (this.clientRows.count()) into a
variable, then after pressing Enter replace the Promise.race with logic that
waits either for noClientFoundMessage to become visible or for
this.clientRows.count() to differ from the captured previousCount (with the
existing timeouts). Reference searchClient, this.clientRows,
this.noClientFoundMessage and this.searchInput when locating where to add the
previousCount capture and the updated wait.

}

/**
* 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.*/);
}
}
66 changes: 66 additions & 0 deletions playwright/tests/client.spec.ts
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();
});
});
Loading