Extract PassDashboard Playwright e2e suite into standalone repo
Some checks failed
E2E Tests / test (push) Has been cancelled

Copies the Playwright end-to-end suite out of the PassDashboard app repo so
it can be run and deployed independently. Nothing was removed from the app
repo - this is a copy.

Carried over verbatim: tests/ (10 spec files, 4 helper modules, README) plus
playwright.config.ts and .env.test.example. The suite drives the deployed app
over HTTP and imports nothing from src/, which is what makes it standalone.

New for this repo:
- package.json with only the deps the suite actually uses (@playwright/test,
  allure-playwright, xlsx, typescript, @types/node) and the e2e scripts.
- tsconfig.json covering tests/ - the app repo's tsconfig.app.json only
  included src/, so these files were never typechecked before.
- .gitea/workflows/e2e-tests.yml for Gitea Actions. The app repo derived ENV
  from github.ref_name; that doesn't apply here since this repo has no
  per-environment branches, so the target is an explicit workflow_dispatch
  input and scheduled/push runs default to develop. Reports are uploaded as
  artifacts rather than deleted.
- .gitignore and README covering local setup and the required CI secrets.

The Jest unit tests under src/__tests__/ were deliberately left in the app
repo: they import application source directly (AuthContext, ProtectedRoute,
cryptoUtils, api) and cannot run without it.

Verified: npx tsc --noEmit is clean and playwright collects all 43 tests
across all 10 spec files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 15:50:50 +03:00
commit 7bf4343f88
23 changed files with 6568 additions and 0 deletions

29
tests/helpers/allure.ts Normal file
View File

@@ -0,0 +1,29 @@
import { test } from '@playwright/test';
export function epic(name: string) {
return test.info().annotations.push({ type: 'epic', description: name });
}
export function feature(name: string) {
return test.info().annotations.push({ type: 'feature', description: name });
}
export function story(name: string) {
return test.info().annotations.push({ type: 'story', description: name });
}
export function severity(level: 'blocker' | 'critical' | 'normal' | 'minor' | 'trivial') {
return test.info().annotations.push({ type: 'severity', description: level });
}
export function tag(...tags: string[]) {
tags.forEach(t => test.info().annotations.push({ type: 'tag', description: t }));
}
export function description(text: string) {
return test.info().annotations.push({ type: 'description', description: text });
}
export function step<T>(name: string, body: () => Promise<T>): Promise<T> {
return test.step(name, body);
}

81
tests/helpers/auth.ts Normal file
View File

@@ -0,0 +1,81 @@
import { Page } from '@playwright/test';
import { getEnvironmentConfig } from './environment';
const env = getEnvironmentConfig();
export const TEST_CREDENTIALS = {
username: env.username,
password: env.password,
loginPath: process.env.TEST_LOGIN_PATH || '/neopaas/portal/login',
dashboardPath: '/dashboard',
};
export async function loginAs(page: Page, identifier: string, password: string): Promise<void> {
console.log('Starting login process...');
// The initial navigation is the most failure-prone step on a loaded local
// machine (headless browser launch/navigation can occasionally stall well
// past a single timeout even though the server itself responds in ~1-3s) -
// retry it in place rather than letting one slow navigation fail the whole
// test and force an expensive full test-level retry.
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await page.goto(TEST_CREDENTIALS.loginPath, { waitUntil: 'domcontentloaded' });
lastError = undefined;
break;
} catch (error) {
lastError = error;
console.log(`Login page navigation attempt ${attempt} failed, retrying...`);
}
}
if (lastError) throw lastError;
await page.waitForTimeout(2000);
// The button's own text is the language you'd switch TO, not the current
// one - so it reads "English" while the page is in Arabic (click to go to
// English), and "العربية" once already in English. The language preference
// persists across logout/login (e.g. via localStorage), so on a second
// login within the same test the page may already be in English - only
// click when the button would actually switch TO English.
console.log('Checking for language switcher...');
const languageArticle = page.locator('article.ant-typography.font-poppins-500.css-1kfsfla').first();
const isVisible = await languageArticle.isVisible({ timeout: 2000 }).catch(() => false);
if (isVisible) {
const text = (await languageArticle.textContent())?.trim();
console.log(`Found language button with text: "${text}"`);
if (text === 'English') {
console.log('Clicking to switch language to English...');
await languageArticle.click();
await page.waitForTimeout(2000);
console.log('✓ Language switched');
} else {
console.log('Already in English - no switch needed');
}
}
console.log('Filling username...');
await page.fill('input[type="email"], input[type="text"], input[name="email"], input[name="username"], input[placeholder*="email" i], input[placeholder*="username" i]', identifier);
console.log('Filling password...');
await page.fill('input[type="password"], input[name="password"]', password);
console.log('Clicking login button...');
await page.click('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")');
console.log('Waiting for redirect...');
await page.waitForURL(/\/(dashboard|terminals)/, { timeout: 30000 }).catch(() => {
console.log('Post-login URL not detected, checking for navigation...');
});
await page.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForTimeout(2000);
console.log('Login completed successfully');
}
export async function login(page: Page): Promise<void> {
await loginAs(page, TEST_CREDENTIALS.username, TEST_CREDENTIALS.password);
}

View File

@@ -0,0 +1,97 @@
import * as fs from 'fs';
import * as path from 'path';
// Loads KEY=VALUE pairs from a local .env-style file into process.env,
// without overwriting anything already set (real env vars / CI secrets
// always win over the file). No external dependency needed for this - the
// format is simple enough to parse directly.
function loadLocalEnvFile(filePath: string): void {
if (!fs.existsSync(filePath)) return;
const content = fs.readFileSync(filePath, 'utf-8');
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const eqIdx = line.indexOf('=');
if (eqIdx === -1) continue;
const key = line.slice(0, eqIdx).trim();
let value = line.slice(eqIdx + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (key && process.env[key] === undefined) {
process.env[key] = value;
}
}
}
// .env.test.local is git-ignored (matches the existing `*.local` gitignore
// pattern) - it holds real local credentials and is never required in CI,
// where GitHub Secrets populate the same variable names directly.
// Resolved from process.cwd() (not __dirname/import.meta.url) since this
// project runs as an ES module, where __dirname isn't available - Playwright
// and its config are always invoked from the project root anyway.
loadLocalEnvFile(path.resolve(process.cwd(), '.env.test.local'));
export type EnvName = 'dev' | 'staging' | 'production';
// Accepts both branch names (develop/staging/main - what CI sets ENV to,
// since e2e-tests.yml derives it from github.ref_name) and semantic names
// (dev/staging/production), so the same ENV value works whether it came
// from a branch push or a manual `ENV=production npm run test:e2e:prod`.
const ENV_ALIASES: Record<string, EnvName> = {
develop: 'dev', dev: 'dev', development: 'dev', devpro: 'dev', local: 'dev', localenv: 'dev',
staging: 'staging', stage: 'staging',
main: 'production', production: 'production', prod: 'production',
};
export function resolveEnvName(): EnvName {
const raw = (process.env.ENV || 'develop').toLowerCase();
return ENV_ALIASES[raw] || 'dev';
}
export interface EnvironmentConfig {
envName: EnvName;
baseURL: string;
username: string;
password: string;
}
export function getEnvironmentConfig(): EnvironmentConfig {
const envName = resolveEnvName();
// dev and staging are genuinely separate deployments/branches - dev has
// its own DEV_URL now (falls back to the old shared stagingenv URL if
// DEV_URL isn't set, e.g. for local runs that haven't configured it yet).
const baseURLs: Record<EnvName, string> = {
dev: process.env.DEV_URL || 'https://stagingenv.babinnovations.com',
staging: 'https://stagingenv.babinnovations.com',
production: 'https://www.babinnovations.com/neopaas/portal',
};
// dev prefers its own DEV_USERNAME/PASSWORD, falling back to the older
// shared TEST_USERNAME/PASSWORD pair for compatibility with existing local
// .env.test.local setups. Staging falls back to that same shared pair
// unless STAGING_TEST_* is explicitly set. Production always requires its
// own PROD_TEST_* pair.
const credentials: Record<EnvName, { username: string; password: string }> = {
dev: {
username: process.env.DEV_USERNAME || process.env.TEST_USERNAME || '',
password: process.env.DEV_PASSWORD || process.env.TEST_PASSWORD || '',
},
staging: {
username: process.env.STAGING_TEST_USERNAME || process.env.TEST_USERNAME || '',
password: process.env.STAGING_TEST_PASSWORD || process.env.TEST_PASSWORD || '',
},
production: {
username: process.env.PROD_TEST_USERNAME || '',
password: process.env.PROD_TEST_PASSWORD || '',
},
};
return {
envName,
baseURL: process.env.BASE_URL || baseURLs[envName],
username: credentials[envName].username,
password: credentials[envName].password,
};
}

172
tests/helpers/navigation.ts Normal file
View File

@@ -0,0 +1,172 @@
import { Page } from '@playwright/test';
/**
* Navigation helper that tries multiple methods to reach a page
* 1. Try clicking navigation link
* 2. Fall back to direct URL navigation
*/
export async function navigateToPage(
page: Page,
linkSelectors: string[],
urlPath: string,
pageName: string
): Promise<void> {
console.log(`Navigating to ${pageName}...`);
// Try clicking navigation links first
for (const selector of linkSelectors) {
const link = page.locator(selector).first();
const isVisible = await link.isVisible({ timeout: 3000 }).catch(() => false);
if (isVisible) {
console.log(`Found ${pageName} link, clicking...`);
await link.click();
await page.waitForLoadState('domcontentloaded', { timeout: 10000 }).catch(() => {});
await page.waitForTimeout(1000);
// Verify we're on the right page
if (page.url().toLowerCase().includes(urlPath.toLowerCase())) {
console.log(`Successfully navigated to ${pageName} via link`);
return;
}
}
}
// Fall back to direct URL navigation
console.log(`${pageName} link not found, using direct URL navigation...`);
try {
await page.goto(urlPath, { timeout: 15000, waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
console.log(`Successfully navigated to ${pageName} via URL`);
} catch (error) {
console.log(`Failed to navigate to ${urlPath}:`, error);
// Try with base URL
const baseUrl = page.context().browser()?.contexts()[0]?.pages()[0]?.url() || 'https://devpro.babinnovations.com';
const fullUrl = new URL(urlPath, baseUrl).href;
console.log(`Retrying with full URL: ${fullUrl}`);
await page.goto(fullUrl, { timeout: 15000, waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
console.log(`Successfully navigated to ${pageName} via full URL`);
}
}
export async function navigateToTransactions(page: Page): Promise<void> {
await navigateToPage(
page,
[
'a:has-text("Transaction")',
'a:has-text("Transactions")',
'[href*="transaction" i]',
'nav a:has-text("Trans")',
],
'/transactions',
'Transactions'
);
}
export async function navigateToTerminals(page: Page): Promise<void> {
await navigateToPage(
page,
[
'a:has-text("Terminal")',
'a:has-text("Terminals")',
'[href*="terminal" i]',
'nav a:has-text("Term")',
],
'/terminals',
'Terminals'
);
}
export async function navigateToRefunds(page: Page): Promise<void> {
await navigateToPage(
page,
[
'a:has-text("Refund")',
'a:has-text("Refunds")',
'[href*="refund" i]',
'nav a:has-text("Ref")',
],
'/refunds',
'Refunds'
);
}
export async function navigateToReconciliation(page: Page): Promise<void> {
await navigateToPage(
page,
[
'a:has-text("Reconcil")',
'a:has-text("Reconciliation")',
'[href*="reconcil" i]',
],
'/reconciliation',
'Reconciliation'
);
}
export async function navigateToManagement(page: Page): Promise<void> {
await navigateToPage(
page,
[
'a:has-text("Manage")',
'a:has-text("Management")',
'[href*="manage" i]',
],
'/management',
'Management'
);
}
export async function navigateToAdmin(page: Page): Promise<void> {
await navigateToPage(
page,
[
'a:has-text("Admin")',
'[href*="admin" i]',
],
'/admin',
'Admin'
);
}
export async function navigateToSettlement(page: Page): Promise<void> {
await navigateToPage(
page,
[
'a:has-text("Settlement")',
'[href*="settlement" i]',
'nav a:has-text("Settle")',
],
'/settlement',
'Settlement'
);
}
export async function navigateToDiscounts(page: Page): Promise<void> {
await navigateToPage(
page,
[
'a:has-text("Discount")',
'a:has-text("Discounts")',
'[href*="discount" i]',
'nav a:has-text("Disc")',
],
'/discounts',
'Discounts & Fees'
);
}
export async function navigateToSettings(page: Page): Promise<void> {
await navigateToPage(
page,
[
'a:has-text("Settings")',
'[href*="settings" i]',
'nav a:has-text("Setting")',
],
'/settings',
'Settings'
);
}