From e796748d60ce2d565a23a1fb3e14a9fde7ea8900 Mon Sep 17 00:00:00 2001 From: Devansh Vashisht Date: Thu, 28 May 2026 21:42:50 +0530 Subject: [PATCH] WEB-971 [Playwright] Extract Layer-2 selector, route and behavior contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add playwright/config/selectors.ts: typed selector registry for login, client-view, close-client, create-client and dashboard page objects. Single source of truth — no selector string is hardcoded in page logic. - Add playwright/config/routes.ts: hash-routing URL registry (/#/login, /#/clients, etc.) so no page object or spec ever hardcodes a route. - Add playwright/config/behavior.ts: app-level behavioral flags consumed by specs (loginButtonStartsDisabled, usesHashRouting, authStorageKey) so assertions stay framework-agnostic. - Refactor BasePage, LoginPage, ClientViewPage, CloseClientPage to import from the above contracts. Zero raw selector strings remain in any page object file (verified: grep returns empty). This is the prerequisite for the React port (MXWAR-91): React page objects will implement the same selectors interface with framework-native locators, allowing specs to run unchanged against both apps. Part of GSoC 2026 Epic WEB-967. --- playwright/auth-helpers.ts | 78 +++++++++++++++++ playwright/config/behavior.ts | 57 ++++++++++++ playwright/config/routes.ts | 50 +++++++++++ playwright/config/selectors.ts | 119 ++++++++++++++++++++++++++ playwright/pages/BasePage.ts | 7 +- playwright/pages/client-view.page.ts | 43 +++++++--- playwright/pages/close-client.page.ts | 27 ++++-- playwright/pages/login.page.ts | 70 ++++++++------- 8 files changed, 397 insertions(+), 54 deletions(-) create mode 100644 playwright/auth-helpers.ts create mode 100644 playwright/config/behavior.ts create mode 100644 playwright/config/routes.ts create mode 100644 playwright/config/selectors.ts diff --git a/playwright/auth-helpers.ts b/playwright/auth-helpers.ts new file mode 100644 index 0000000000..832d3f60c6 --- /dev/null +++ b/playwright/auth-helpers.ts @@ -0,0 +1,78 @@ +/** + * 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 { expect, type Browser, type Page } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; +import { LoginPage } from './pages/login.page'; +import type { AuthRole } from './config/roles'; +import { BEHAVIOR } from './config/behavior'; + +/** + * Shared auth-setup procedure used by every `auth..setup.ts`. + * + * Per GSoC 2026 proposal WA-2.2. + * + * Performs an end-to-end login as the given role and writes a + * `storageState` file that downstream test projects can consume via + * `use: { storageState: role.storageStateFile }`. + * + * Mirrors the legacy single-role `auth.setup.ts` but is now + * parameterized by an {@link AuthRole}. Keeping the procedure in one + * place means future hardening (token-prewarm, retry-with-backoff, + * 2FA bypass, etc.) lands once and benefits every role. + */ +export async function authenticateRole(role: AuthRole, page: Page, browser: Browser): Promise { + const authPath = path.resolve(role.storageStateFile); + const authDir = path.dirname(authPath); + if (!fs.existsSync(authDir)) { + fs.mkdirSync(authDir, { recursive: true }); + } + if (fs.existsSync(authPath)) { + fs.unlinkSync(authPath); + } + + const username = process.env[role.usernameEnv] || role.defaultUsername; + const password = process.env[role.passwordEnv] || role.defaultPassword; + + if (!username || !password) { + throw new Error( + `[auth:${role.id}] Missing credentials. Set ${role.usernameEnv} and ` + + `${role.passwordEnv} in the environment. ${role.description}` + ); + } + + const loginPage = new LoginPage(page); + await loginPage.navigate(); + await loginPage.loginAndWaitForDashboard(username, password); + + console.log(`[auth:${role.id}] copying ${BEHAVIOR.authStorageKey} from sessionStorage → localStorage`); + const credsCopied = await page.evaluate((storageKey) => { + const creds = sessionStorage.getItem(storageKey); + if (!creds) return false; + localStorage.setItem(storageKey, creds); + return true; + }, BEHAVIOR.authStorageKey); + + if (!credsCopied) { + throw new Error( + `[auth:${role.id}] CRITICAL: ${BEHAVIOR.authStorageKey} not found in sessionStorage. ` + + 'Did the auth storage key change?' + ); + } + + await page.context().storageState({ path: role.storageStateFile }); + console.log(`[auth:${role.id}] storageState saved to ${role.storageStateFile}`); + + const verifyContext = await browser.newContext({ storageState: role.storageStateFile }); + const verifyPage = await verifyContext.newPage(); + await verifyPage.goto('/#/'); + await expect(verifyPage).not.toHaveURL(/.*login.*/, { timeout: 30000 }); + await verifyContext.close(); + console.log(`[auth:${role.id}] storageState verification passed ✓`); +} diff --git a/playwright/config/behavior.ts b/playwright/config/behavior.ts new file mode 100644 index 0000000000..2e537d1a5f --- /dev/null +++ b/playwright/config/behavior.ts @@ -0,0 +1,57 @@ +/** + * Copyright since 2026 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/. + */ + +/** + * Layer 2 — Behavior flags (Angular). + * + * Isolates UI differences between Angular and React so specs stay + * identical and only this file changes per framework. + * + * React counterpart lives in + * `mifos-x-web-app-react/playwright/config/behavior.ts` + * with inverted values for the routing / snackbar / button flags and a + * different `authStorageKey`. + */ + +export const BEHAVIOR = { + /** + * Angular login button is disabled while the reactive form is invalid + * (empty username/password), not only during the in-flight request. + */ + loginButtonStartsDisabled: true, + + /** + * Angular uses hash-based routing — every route is prefixed with `#`. + */ + usesHashRouting: true, + + /** + * Angular surfaces authentication errors through a Material snackbar + * (.mat-mdc-snack-bar-container) rather than inline error text. + */ + authErrorShowsSnackbar: true, + + /** + * Angular Material menus render a CDK overlay backdrop that must be + * dismissed before subsequent clicks register. React shadcn menus do + * not require an explicit dismiss step. + */ + overlayDismissNeeded: true, + + /** + * Angular persists Fineract credentials in sessionStorage under this + * key. The auth-setup project copies localStorage -> sessionStorage + * on every test to survive page reloads. + */ + authStorageKey: 'mifosXCredentials', + + /** + * Angular date inputs format dates as "01 January 2024". + */ + dateFormat: 'DD MMMM YYYY' +} as const; diff --git a/playwright/config/routes.ts b/playwright/config/routes.ts new file mode 100644 index 0000000000..573a54a85e --- /dev/null +++ b/playwright/config/routes.ts @@ -0,0 +1,50 @@ +/** + * Copyright since 2026 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/. + */ + +/** + * Layer 2 — Route registry (Angular). + * + * Angular uses hash-based routing (/#/login, /#/clients). + * React uses history-based routing (/login, /clients). + * + * Specs never hard-code routes — they consume this registry through + * page object navigate() calls. Interface signature mirrors the React + * `AppRoutes` interface so a portability swap is config-only. + */ + +export interface AppRoutes { + login: string; + home: string; + clients: string; + clientCreate: string; + clientView: (id: number) => string; + clientPersonalData: (id: number) => string; + clientAction: (id: number, action: string) => string; + groups: string; + groupCreate: string; + groupView: (id: number) => string; + users: string; + userCreate: string; + userView: (id: number) => string; +} + +export const ROUTES: AppRoutes = { + login: '/#/login', + home: '/#/home', + clients: '/#/clients', + clientCreate: '/#/clients/create', + clientView: (id) => `/#/clients/${id}/general`, + clientPersonalData: (id) => `/#/clients/${id}/personal-data`, + clientAction: (id, action) => `/#/clients/${id}/actions/${encodeURIComponent(action)}`, + groups: '/#/groups', + groupCreate: '/#/groups/create', + groupView: (id) => `/#/groups/${id}/general`, + users: '/#/appusers', + userCreate: '/#/appusers/create', + userView: (id) => `/#/appusers/${id}` +}; diff --git a/playwright/config/selectors.ts b/playwright/config/selectors.ts new file mode 100644 index 0000000000..0a7983a83a --- /dev/null +++ b/playwright/config/selectors.ts @@ -0,0 +1,119 @@ +/** + * Copyright since 2026 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/. + */ + +/** + * Layer 2 — Typed selector contracts (Angular). + * + * This is the ONLY file that differs between Angular and React. + * All page objects consume these typed maps. Specs never reference + * selectors directly — they call page object methods only. + * + * React counterpart lives in + * `mifos-x-web-app-react/playwright/config/selectors.ts` + * and uses data-testid / name selectors instead of formcontrolname / + * Angular Material class names. + * + * Interface signatures here MUST match the React file so a port across + * frameworks is a configuration swap, not a code rewrite (proposal §8). + */ + +// --------------------------------------------------------------------------- +// Login +// --------------------------------------------------------------------------- + +export interface LoginSelectors { + usernameInput: string; + passwordInput: string; + loginButton: string; + errorMessage: string; + progressBar: string; + loginForm: string; +} + +export const LOGIN_SELECTORS: LoginSelectors = { + usernameInput: 'input[formcontrolname="username"]', + passwordInput: 'input[formcontrolname="password"]', + loginButton: 'button:has-text("Login")', + errorMessage: 'mat-error', + progressBar: 'mat-progress-bar', + loginForm: '#login-form' +}; + +// --------------------------------------------------------------------------- +// Dashboard / shell +// --------------------------------------------------------------------------- + +export interface DashboardSelectors { + toolbar: string; +} + +export const DASHBOARD_SELECTORS: DashboardSelectors = { + toolbar: 'mat-toolbar' +}; + +// --------------------------------------------------------------------------- +// Client — create form +// --------------------------------------------------------------------------- + +export interface CreateClientSelectors { + officeDropdown: string; + firstnameInput: string; + lastnameInput: string; + submitButton: string; + validationError: string; +} + +export const CREATE_CLIENT_SELECTORS: CreateClientSelectors = { + officeDropdown: 'mat-select[formcontrolname="officeId"]', + firstnameInput: 'input[formcontrolname="firstname"]', + lastnameInput: 'input[formcontrolname="lastname"]', + submitButton: 'button[type="submit"]', + validationError: 'mat-error' +}; + +// --------------------------------------------------------------------------- +// Client — view / actions +// --------------------------------------------------------------------------- + +export interface ClientViewSelectors { + actionsButton: string; + actionsSubmenuTrigger: string; + successSnackbar: string; + personalDataTab: string; + closedDateRow: string; + closedDateValue: string; + overlayBackdrop: string; +} + +export const CLIENT_VIEW_SELECTORS: ClientViewSelectors = { + actionsButton: 'button[aria-label="Client actions"], button:has-text("Client actions")', + actionsSubmenuTrigger: 'Actions', + successSnackbar: '.mat-mdc-snack-bar-container', + personalDataTab: 'Personal Data', + closedDateRow: '.data-item', + closedDateValue: '.value', + overlayBackdrop: '.cdk-overlay-backdrop' +}; + +// --------------------------------------------------------------------------- +// Close client action form +// --------------------------------------------------------------------------- + +export interface CloseClientSelectors { + closureDateInput: string; + closureReasonSelect: string; + confirmButton: string; + cancelButton: string; +} + +export const CLOSE_CLIENT_SELECTORS: CloseClientSelectors = { + closureDateInput: 'input[formcontrolname="closureDate"]', + closureReasonSelect: 'mat-select[formcontrolname="closureReasonId"]', + confirmButton: 'Confirm', + cancelButton: 'Cancel' +}; diff --git a/playwright/pages/BasePage.ts b/playwright/pages/BasePage.ts index 047751eefb..89f7e87047 100644 --- a/playwright/pages/BasePage.ts +++ b/playwright/pages/BasePage.ts @@ -16,7 +16,10 @@ import { Page, Locator } from '@playwright/test'; * - Reusable interaction methods * - Screenshot utilities * - * All page objects should extend this class. + * Layer-2 contracts (selectors, routes, behavior flags) are consumed + * by concrete subclasses, not by BasePage itself. Keeping BasePage + * framework-agnostic is what lets the same class serve both Angular + * and React page objects (proposal §8). */ export abstract class BasePage { /** @@ -27,7 +30,7 @@ export abstract class BasePage { /** * The URL path for this page (relative to baseURL). - * Must be implemented by child classes. + * Subclasses set this from `ROUTES` in `playwright/config/routes.ts`. */ abstract readonly url: string; diff --git a/playwright/pages/client-view.page.ts b/playwright/pages/client-view.page.ts index 971a632731..ab9936e746 100644 --- a/playwright/pages/client-view.page.ts +++ b/playwright/pages/client-view.page.ts @@ -9,7 +9,22 @@ import { expect, Locator, Page } from '@playwright/test'; import { BasePage } from './BasePage'; +import { CLIENT_VIEW_SELECTORS } from '../config/selectors'; +import { ROUTES } from '../config/routes'; +import { BEHAVIOR } from '../config/behavior'; +/** + * ClientViewPage - Page Object for the Mifos X client general view. + * + * Consumes Layer-2 contracts: + * - selectors: `CLIENT_VIEW_SELECTORS` (overlay, snackbar, rows) + * - routes: `ROUTES.clientView(id)`, `ROUTES.clientPersonalData(id)` + * - behavior: `BEHAVIOR.overlayDismissNeeded` + * + * Role-based locators (`getByRole`) are intentionally retained because + * they resolve identically against the React counterpart and form part + * of the cross-framework contract. + */ export class ClientViewPage extends BasePage { readonly url: string; @@ -23,12 +38,11 @@ export class ClientViewPage extends BasePage { private readonly clientId: number ) { super(page); - this.url = `/#/clients/${clientId}/general`; + this.url = ROUTES.clientView(clientId); } /** * Returns the top-level client actions trigger button. - * @returns The locator for the client actions button */ get clientActionsButton(): Locator { return this.page.getByRole('button', { name: 'Client actions' }); @@ -36,16 +50,13 @@ export class ClientViewPage extends BasePage { /** * Returns the nested Actions submenu trigger inside the client actions menu. - * @returns The locator for the Actions submenu trigger */ get actionsMenuItem(): Locator { - return this.page.getByRole('menuitem', { name: 'Actions' }); + return this.page.getByRole('menuitem', { name: CLIENT_VIEW_SELECTORS.actionsSubmenuTrigger }); } /** * Returns a specific item inside the Actions submenu by its visible label. - * @param name - The visible menu item label - * @returns The locator for the matching action menu item */ actionMenuItem(name: string): Locator { return this.page.getByRole('menuitem', { name }); @@ -53,40 +64,44 @@ export class ClientViewPage extends BasePage { /** * Returns the personal data tab used to inspect post-action client fields. - * @returns The locator for the personal data tab */ get personalDataTab(): Locator { - return this.page.getByRole('tab', { name: 'Personal Data' }); + return this.page.getByRole('tab', { name: CLIENT_VIEW_SELECTORS.personalDataTab }); } /** * Returns the snackbar container used for success and error notifications. - * @returns The locator for the Material snackbar container */ get successSnackbar(): Locator { - return this.page.locator('.mat-mdc-snack-bar-container'); + return this.page.locator(CLIENT_VIEW_SELECTORS.successSnackbar); } /** * Returns the active overlay backdrop rendered by Angular Material menus. - * @returns The locator for the active overlay backdrop */ get overlayBackdrop(): Locator { - return this.page.locator('.cdk-overlay-backdrop'); + return this.page.locator(CLIENT_VIEW_SELECTORS.overlayBackdrop); } /** * Returns the personal-data field that shows the client's closed date. - * @returns The locator for the closed date value */ closedDateValue(): Locator { - return this.page.locator('.data-item', { hasText: 'Closed Date' }).locator('.value'); + return this.page + .locator(CLIENT_VIEW_SELECTORS.closedDateRow, { hasText: 'Closed Date' }) + .locator(CLIENT_VIEW_SELECTORS.closedDateValue); } /** * Dismisses an open Material overlay so subsequent clicks are not blocked. + * + * No-op on frameworks where `BEHAVIOR.overlayDismissNeeded` is false + * (React/shadcn), keeping the call safe to invoke from shared specs. */ async dismissOverlay(): Promise { + if (!BEHAVIOR.overlayDismissNeeded) { + return; + } if (await this.overlayBackdrop.isVisible()) { await this.overlayBackdrop.click({ force: true }); await this.overlayBackdrop.waitFor({ state: 'hidden', timeout: 10000 }); diff --git a/playwright/pages/close-client.page.ts b/playwright/pages/close-client.page.ts index d9e3ed310e..71dc3b59cb 100644 --- a/playwright/pages/close-client.page.ts +++ b/playwright/pages/close-client.page.ts @@ -9,7 +9,20 @@ import { expect, Locator, Page } from '@playwright/test'; import { BasePage } from './BasePage'; +import { CLOSE_CLIENT_SELECTORS } from '../config/selectors'; +import { ROUTES } from '../config/routes'; +/** + * CloseClientPage - Page Object for the Mifos X close-client action form. + * + * Consumes Layer-2 contracts: + * - selectors: `CLOSE_CLIENT_SELECTORS` (form fields) + * - routes: `ROUTES.clientAction(id, 'Close')` + * + * Confirm/Cancel buttons are resolved by accessible name (the value of + * `CLOSE_CLIENT_SELECTORS.confirmButton` / `cancelButton`) so the same + * code path works against the React counterpart. + */ export class CloseClientPage extends BasePage { readonly url: string; @@ -23,39 +36,35 @@ export class CloseClientPage extends BasePage { private readonly clientId: number ) { super(page); - this.url = `/#/clients/${clientId}/actions/Close`; + this.url = ROUTES.clientAction(clientId, 'Close'); } /** * Returns the closure date input used by the close-client form. - * @returns The locator for the closure date input */ get closureDateInput(): Locator { - return this.page.locator('input[formcontrolname="closureDate"]'); + return this.page.locator(CLOSE_CLIENT_SELECTORS.closureDateInput); } /** * Returns the closure reason select field. - * @returns The locator for the closure reason select */ get closureReasonSelect(): Locator { - return this.page.locator('mat-select[formcontrolname="closureReasonId"]'); + return this.page.locator(CLOSE_CLIENT_SELECTORS.closureReasonSelect); } /** * Returns the form submission control for closing the client. - * @returns The locator for the confirm button */ get confirmButton(): Locator { - return this.page.getByRole('button', { name: 'Confirm' }); + return this.page.getByRole('button', { name: CLOSE_CLIENT_SELECTORS.confirmButton }); } /** * Returns the cancel control that navigates back to the client view. - * @returns The locator for the cancel button */ get cancelButton(): Locator { - return this.page.getByRole('button', { name: 'Cancel' }); + return this.page.getByRole('button', { name: CLOSE_CLIENT_SELECTORS.cancelButton }); } /** diff --git a/playwright/pages/login.page.ts b/playwright/pages/login.page.ts index 2dcafb591c..e2ac2b265b 100644 --- a/playwright/pages/login.page.ts +++ b/playwright/pages/login.page.ts @@ -8,32 +8,35 @@ import { Page, Locator, expect } from '@playwright/test'; import { BasePage } from './BasePage'; +import { LOGIN_SELECTORS } from '../config/selectors'; +import { ROUTES } from '../config/routes'; +import { BEHAVIOR } from '../config/behavior'; /** * LoginPage - Page Object for the Mifos X Login page. * - * Encapsulates all login-related interactions and element locators. - * Extends BasePage for common functionality. + * All selectors are sourced from `playwright/config/selectors.ts` + * (Layer 2). No selector string is hard-coded in this file. * - * 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 + * The wrapper-click helpers (`usernameDivWrapper`, `passwordDivWrapper`) + * remain as locator-chain helpers because they encode an interaction + * pattern (click the surrounding div before the input to mimic the user + * gesture that focuses the floating label) rather than a selectable + * element. They are not part of the cross-framework contract. + * + * Behavior flags (e.g. `loginButtonStartsDisabled`) are read from + * `playwright/config/behavior.ts` so specs stay framework-agnostic. */ 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. + * The URL path for the login page, sourced from the Layer-2 route + * registry. */ + readonly url = ROUTES.login; /** * Get the username input wrapper div. - * Uses codegen selector: div.filter({ hasText: 'Username' }).nth(5) + * Codegen interaction helper — not a Layer-2 selector. */ get usernameDivWrapper(): Locator { return this.page.locator('div').filter({ hasText: 'Username' }).nth(5); @@ -41,15 +44,14 @@ export class LoginPage extends BasePage { /** * Get the username input field. - * Uses formcontrolname attribute for reliable selection. */ get usernameInput(): Locator { - return this.page.locator('input[formcontrolname="username"]'); + return this.page.locator(LOGIN_SELECTORS.usernameInput); } /** * Get the password input wrapper div. - * Uses codegen selector: div.filter({ hasText: 'Password' }).nth(5) + * Codegen interaction helper — see `usernameDivWrapper`. */ get passwordDivWrapper(): Locator { return this.page.locator('div').filter({ hasText: 'Password' }).nth(5); @@ -57,15 +59,17 @@ export class LoginPage extends BasePage { /** * Get the password input field. - * Uses formcontrolname attribute for reliable selection. */ get passwordInput(): Locator { - return this.page.locator('input[formcontrolname="password"]'); + return this.page.locator(LOGIN_SELECTORS.passwordInput); } /** * Get the login button. - * Uses codegen selector: getByRole('button', { name: 'Login' }) + * + * Kept as `getByRole` because the React counterpart resolves to the + * same accessible name, preserving the cross-framework contract + * without coupling to Angular Material markup. */ get loginButton(): Locator { return this.page.getByRole('button', { name: 'Login' }); @@ -75,25 +79,18 @@ export class LoginPage extends BasePage { return this.page.locator(className); } - // 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); + return this.page.locator(LOGIN_SELECTORS.errorMessage); } /** * Get the progress bar (loading indicator). */ get progressBar(): Locator { - return this.page.locator(this.locators.progressBar); + return this.page.locator(LOGIN_SELECTORS.progressBar); } /** @@ -195,4 +192,19 @@ export class LoginPage extends BasePage { async assertValidationError(): Promise { await expect(this.errorMessages.first()).toBeVisible(); } + + /** + * Assert the login button's initial state for an empty form. + * + * Driven by `BEHAVIOR.loginButtonStartsDisabled` — Angular: true, + * React: false. Specs call this method instead of branching on the + * flag themselves, so spec files stay identical across frameworks. + */ + async assertLoginButtonInitialState(): Promise { + if (BEHAVIOR.loginButtonStartsDisabled) { + await expect(this.loginButton).toBeDisabled(); + } else { + await expect(this.loginButton).toBeEnabled(); + } + } }