-
Notifications
You must be signed in to change notification settings - Fork 930
WEB-971 [Playwright] Extract Layer-2 selector, route and behavior con… #3627
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,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(); | ||
| console.log(`[auth:${role.id}] storageState verification passed ✓`); | ||
| } | ||
| 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
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. 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win Eliminate duplicate auth-storage key definitions to avoid contract drift. Line 51 introduces ♻️ 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 |
||
|
|
||
| /** | ||
| * Angular date inputs format dates as "01 January 2024". | ||
| */ | ||
| dateFormat: 'DD MMMM YYYY' | ||
| } as const; | ||
| 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}` | ||
| }; |
| 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' | ||
| }; |
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.
Ensure
verifyContextis 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
🤖 Prompt for AI Agents