Skip to content

Commit e796748

Browse files
committed
WEB-971 [Playwright] Extract Layer-2 selector, route and behavior contracts
- 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.
1 parent 8ecadcc commit e796748

8 files changed

Lines changed: 397 additions & 54 deletions

File tree

playwright/auth-helpers.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* Copyright since 2025 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
7+
*/
8+
9+
import { expect, type Browser, type Page } from '@playwright/test';
10+
import fs from 'fs';
11+
import path from 'path';
12+
import { LoginPage } from './pages/login.page';
13+
import type { AuthRole } from './config/roles';
14+
import { BEHAVIOR } from './config/behavior';
15+
16+
/**
17+
* Shared auth-setup procedure used by every `auth.<role>.setup.ts`.
18+
*
19+
* Per GSoC 2026 proposal WA-2.2.
20+
*
21+
* Performs an end-to-end login as the given role and writes a
22+
* `storageState` file that downstream test projects can consume via
23+
* `use: { storageState: role.storageStateFile }`.
24+
*
25+
* Mirrors the legacy single-role `auth.setup.ts` but is now
26+
* parameterized by an {@link AuthRole}. Keeping the procedure in one
27+
* place means future hardening (token-prewarm, retry-with-backoff,
28+
* 2FA bypass, etc.) lands once and benefits every role.
29+
*/
30+
export async function authenticateRole(role: AuthRole, page: Page, browser: Browser): Promise<void> {
31+
const authPath = path.resolve(role.storageStateFile);
32+
const authDir = path.dirname(authPath);
33+
if (!fs.existsSync(authDir)) {
34+
fs.mkdirSync(authDir, { recursive: true });
35+
}
36+
if (fs.existsSync(authPath)) {
37+
fs.unlinkSync(authPath);
38+
}
39+
40+
const username = process.env[role.usernameEnv] || role.defaultUsername;
41+
const password = process.env[role.passwordEnv] || role.defaultPassword;
42+
43+
if (!username || !password) {
44+
throw new Error(
45+
`[auth:${role.id}] Missing credentials. Set ${role.usernameEnv} and ` +
46+
`${role.passwordEnv} in the environment. ${role.description}`
47+
);
48+
}
49+
50+
const loginPage = new LoginPage(page);
51+
await loginPage.navigate();
52+
await loginPage.loginAndWaitForDashboard(username, password);
53+
54+
console.log(`[auth:${role.id}] copying ${BEHAVIOR.authStorageKey} from sessionStorage → localStorage`);
55+
const credsCopied = await page.evaluate((storageKey) => {
56+
const creds = sessionStorage.getItem(storageKey);
57+
if (!creds) return false;
58+
localStorage.setItem(storageKey, creds);
59+
return true;
60+
}, BEHAVIOR.authStorageKey);
61+
62+
if (!credsCopied) {
63+
throw new Error(
64+
`[auth:${role.id}] CRITICAL: ${BEHAVIOR.authStorageKey} not found in sessionStorage. ` +
65+
'Did the auth storage key change?'
66+
);
67+
}
68+
69+
await page.context().storageState({ path: role.storageStateFile });
70+
console.log(`[auth:${role.id}] storageState saved to ${role.storageStateFile}`);
71+
72+
const verifyContext = await browser.newContext({ storageState: role.storageStateFile });
73+
const verifyPage = await verifyContext.newPage();
74+
await verifyPage.goto('/#/');
75+
await expect(verifyPage).not.toHaveURL(/.*login.*/, { timeout: 30000 });
76+
await verifyContext.close();
77+
console.log(`[auth:${role.id}] storageState verification passed ✓`);
78+
}

playwright/config/behavior.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Copyright since 2026 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
7+
*/
8+
9+
/**
10+
* Layer 2 — Behavior flags (Angular).
11+
*
12+
* Isolates UI differences between Angular and React so specs stay
13+
* identical and only this file changes per framework.
14+
*
15+
* React counterpart lives in
16+
* `mifos-x-web-app-react/playwright/config/behavior.ts`
17+
* with inverted values for the routing / snackbar / button flags and a
18+
* different `authStorageKey`.
19+
*/
20+
21+
export const BEHAVIOR = {
22+
/**
23+
* Angular login button is disabled while the reactive form is invalid
24+
* (empty username/password), not only during the in-flight request.
25+
*/
26+
loginButtonStartsDisabled: true,
27+
28+
/**
29+
* Angular uses hash-based routing — every route is prefixed with `#`.
30+
*/
31+
usesHashRouting: true,
32+
33+
/**
34+
* Angular surfaces authentication errors through a Material snackbar
35+
* (.mat-mdc-snack-bar-container) rather than inline error text.
36+
*/
37+
authErrorShowsSnackbar: true,
38+
39+
/**
40+
* Angular Material menus render a CDK overlay backdrop that must be
41+
* dismissed before subsequent clicks register. React shadcn menus do
42+
* not require an explicit dismiss step.
43+
*/
44+
overlayDismissNeeded: true,
45+
46+
/**
47+
* Angular persists Fineract credentials in sessionStorage under this
48+
* key. The auth-setup project copies localStorage -> sessionStorage
49+
* on every test to survive page reloads.
50+
*/
51+
authStorageKey: 'mifosXCredentials',
52+
53+
/**
54+
* Angular date inputs format dates as "01 January 2024".
55+
*/
56+
dateFormat: 'DD MMMM YYYY'
57+
} as const;

playwright/config/routes.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Copyright since 2026 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
7+
*/
8+
9+
/**
10+
* Layer 2 — Route registry (Angular).
11+
*
12+
* Angular uses hash-based routing (/#/login, /#/clients).
13+
* React uses history-based routing (/login, /clients).
14+
*
15+
* Specs never hard-code routes — they consume this registry through
16+
* page object navigate() calls. Interface signature mirrors the React
17+
* `AppRoutes` interface so a portability swap is config-only.
18+
*/
19+
20+
export interface AppRoutes {
21+
login: string;
22+
home: string;
23+
clients: string;
24+
clientCreate: string;
25+
clientView: (id: number) => string;
26+
clientPersonalData: (id: number) => string;
27+
clientAction: (id: number, action: string) => string;
28+
groups: string;
29+
groupCreate: string;
30+
groupView: (id: number) => string;
31+
users: string;
32+
userCreate: string;
33+
userView: (id: number) => string;
34+
}
35+
36+
export const ROUTES: AppRoutes = {
37+
login: '/#/login',
38+
home: '/#/home',
39+
clients: '/#/clients',
40+
clientCreate: '/#/clients/create',
41+
clientView: (id) => `/#/clients/${id}/general`,
42+
clientPersonalData: (id) => `/#/clients/${id}/personal-data`,
43+
clientAction: (id, action) => `/#/clients/${id}/actions/${encodeURIComponent(action)}`,
44+
groups: '/#/groups',
45+
groupCreate: '/#/groups/create',
46+
groupView: (id) => `/#/groups/${id}/general`,
47+
users: '/#/appusers',
48+
userCreate: '/#/appusers/create',
49+
userView: (id) => `/#/appusers/${id}`
50+
};

playwright/config/selectors.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* Copyright since 2026 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
7+
*/
8+
9+
/**
10+
* Layer 2 — Typed selector contracts (Angular).
11+
*
12+
* This is the ONLY file that differs between Angular and React.
13+
* All page objects consume these typed maps. Specs never reference
14+
* selectors directly — they call page object methods only.
15+
*
16+
* React counterpart lives in
17+
* `mifos-x-web-app-react/playwright/config/selectors.ts`
18+
* and uses data-testid / name selectors instead of formcontrolname /
19+
* Angular Material class names.
20+
*
21+
* Interface signatures here MUST match the React file so a port across
22+
* frameworks is a configuration swap, not a code rewrite (proposal §8).
23+
*/
24+
25+
// ---------------------------------------------------------------------------
26+
// Login
27+
// ---------------------------------------------------------------------------
28+
29+
export interface LoginSelectors {
30+
usernameInput: string;
31+
passwordInput: string;
32+
loginButton: string;
33+
errorMessage: string;
34+
progressBar: string;
35+
loginForm: string;
36+
}
37+
38+
export const LOGIN_SELECTORS: LoginSelectors = {
39+
usernameInput: 'input[formcontrolname="username"]',
40+
passwordInput: 'input[formcontrolname="password"]',
41+
loginButton: 'button:has-text("Login")',
42+
errorMessage: 'mat-error',
43+
progressBar: 'mat-progress-bar',
44+
loginForm: '#login-form'
45+
};
46+
47+
// ---------------------------------------------------------------------------
48+
// Dashboard / shell
49+
// ---------------------------------------------------------------------------
50+
51+
export interface DashboardSelectors {
52+
toolbar: string;
53+
}
54+
55+
export const DASHBOARD_SELECTORS: DashboardSelectors = {
56+
toolbar: 'mat-toolbar'
57+
};
58+
59+
// ---------------------------------------------------------------------------
60+
// Client — create form
61+
// ---------------------------------------------------------------------------
62+
63+
export interface CreateClientSelectors {
64+
officeDropdown: string;
65+
firstnameInput: string;
66+
lastnameInput: string;
67+
submitButton: string;
68+
validationError: string;
69+
}
70+
71+
export const CREATE_CLIENT_SELECTORS: CreateClientSelectors = {
72+
officeDropdown: 'mat-select[formcontrolname="officeId"]',
73+
firstnameInput: 'input[formcontrolname="firstname"]',
74+
lastnameInput: 'input[formcontrolname="lastname"]',
75+
submitButton: 'button[type="submit"]',
76+
validationError: 'mat-error'
77+
};
78+
79+
// ---------------------------------------------------------------------------
80+
// Client — view / actions
81+
// ---------------------------------------------------------------------------
82+
83+
export interface ClientViewSelectors {
84+
actionsButton: string;
85+
actionsSubmenuTrigger: string;
86+
successSnackbar: string;
87+
personalDataTab: string;
88+
closedDateRow: string;
89+
closedDateValue: string;
90+
overlayBackdrop: string;
91+
}
92+
93+
export const CLIENT_VIEW_SELECTORS: ClientViewSelectors = {
94+
actionsButton: 'button[aria-label="Client actions"], button:has-text("Client actions")',
95+
actionsSubmenuTrigger: 'Actions',
96+
successSnackbar: '.mat-mdc-snack-bar-container',
97+
personalDataTab: 'Personal Data',
98+
closedDateRow: '.data-item',
99+
closedDateValue: '.value',
100+
overlayBackdrop: '.cdk-overlay-backdrop'
101+
};
102+
103+
// ---------------------------------------------------------------------------
104+
// Close client action form
105+
// ---------------------------------------------------------------------------
106+
107+
export interface CloseClientSelectors {
108+
closureDateInput: string;
109+
closureReasonSelect: string;
110+
confirmButton: string;
111+
cancelButton: string;
112+
}
113+
114+
export const CLOSE_CLIENT_SELECTORS: CloseClientSelectors = {
115+
closureDateInput: 'input[formcontrolname="closureDate"]',
116+
closureReasonSelect: 'mat-select[formcontrolname="closureReasonId"]',
117+
confirmButton: 'Confirm',
118+
cancelButton: 'Cancel'
119+
};

playwright/pages/BasePage.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ import { Page, Locator } from '@playwright/test';
1616
* - Reusable interaction methods
1717
* - Screenshot utilities
1818
*
19-
* All page objects should extend this class.
19+
* Layer-2 contracts (selectors, routes, behavior flags) are consumed
20+
* by concrete subclasses, not by BasePage itself. Keeping BasePage
21+
* framework-agnostic is what lets the same class serve both Angular
22+
* and React page objects (proposal §8).
2023
*/
2124
export abstract class BasePage {
2225
/**
@@ -27,7 +30,7 @@ export abstract class BasePage {
2730

2831
/**
2932
* The URL path for this page (relative to baseURL).
30-
* Must be implemented by child classes.
33+
* Subclasses set this from `ROUTES` in `playwright/config/routes.ts`.
3134
*/
3235
abstract readonly url: string;
3336

0 commit comments

Comments
 (0)