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
78 changes: 78 additions & 0 deletions playwright/auth-helpers.ts
Original file line number Diff line number Diff line change
@@ -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.<role>.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<void> {
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();
Comment on lines +72 to +76
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Ensure verifyContext is always closed.

If the assertion on Line 74 fails, Line 75 is skipped and the context leaks.

Proposed diff
   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();
+  try {
+    const verifyPage = await verifyContext.newPage();
+    await verifyPage.goto('/#/');
+    await expect(verifyPage).not.toHaveURL(/.*login.*/, { timeout: 30000 });
+  } finally {
+    await verifyContext.close();
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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();
const verifyContext = await browser.newContext({ storageState: role.storageStateFile });
try {
const verifyPage = await verifyContext.newPage();
await verifyPage.goto('/#/');
await expect(verifyPage).not.toHaveURL(/.*login.*/, { timeout: 30000 });
} finally {
await verifyContext.close();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@playwright/auth-helpers.ts` around lines 71 - 75, The verifyContext created
via browser.newContext (and verifyPage) may leak if the assertion await
expect(verifyPage).not.toHaveURL(/.*login.*/...) throws; wrap the navigation +
assertion in a try/finally so verifyContext.close() always runs in the finally
block (and close verifyPage if created separately). Specifically, modify the
block around verifyContext/verifyPage and the expect call to ensure
verifyContext.close() is invoked in a finally clause so the context is always
cleaned up even on assertion failures.

console.log(`[auth:${role.id}] storageState verification passed ✓`);
}
57 changes: 57 additions & 0 deletions playwright/config/behavior.ts
Original file line number Diff line number Diff line change
@@ -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',
Comment on lines +47 to +51
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Eliminate duplicate auth-storage key definitions to avoid contract drift.

Line 51 introduces BEHAVIOR.authStorageKey, but playwright/auth.setup.ts still hardcodes 'mifosXCredentials' (Lines 32-41 in the provided snippet). If one side changes, auth bootstrap can silently break across all tests.

♻️ Proposed follow-up (outside this file) to consume the contract
+import { BEHAVIOR } from './config/behavior';

-const creds = sessionStorage.getItem('mifosXCredentials');
+const creds = sessionStorage.getItem(BEHAVIOR.authStorageKey);

-localStorage.setItem('mifosXCredentials', creds);
+localStorage.setItem(BEHAVIOR.authStorageKey, creds);

-throw new Error('CRITICAL: mifosXCredentials not found in sessionStorage. ' + 'Did the auth storage key change?');
+throw new Error(`CRITICAL: ${BEHAVIOR.authStorageKey} not found in sessionStorage. Did the auth storage key change?`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@playwright/config/behavior.ts` around lines 47 - 51, Tests define the auth
storage key in BEHAVIOR.authStorageKey but auth.setup.ts still hardcodes
'mifosXCredentials', causing duplicate definitions; fix by importing BEHAVIOR
(or the exported constant that contains authStorageKey) into the auth.setup
module and replace the literal 'mifosXCredentials' with BEHAVIOR.authStorageKey
so all consumers use the single source of truth.


/**
* Angular date inputs format dates as "01 January 2024".
*/
dateFormat: 'DD MMMM YYYY'
} as const;
50 changes: 50 additions & 0 deletions playwright/config/routes.ts
Original file line number Diff line number Diff line change
@@ -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}`
};
119 changes: 119 additions & 0 deletions playwright/config/selectors.ts
Original file line number Diff line number Diff line change
@@ -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'
};
7 changes: 5 additions & 2 deletions playwright/pages/BasePage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand All @@ -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;

Expand Down
Loading
Loading