From 7bf4343f88cd2cd9f22d32bebeca216f2ca1d016 Mon Sep 17 00:00:00 2001 From: Rediet Wogayehu Date: Mon, 3 Aug 2026 15:50:50 +0300 Subject: [PATCH] Extract PassDashboard Playwright e2e suite into standalone repo 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) --- .env.test.example | 34 + .gitea/workflows/e2e-tests.yml | 92 +++ .gitignore | 28 + README.md | 94 +++ package-lock.json | 295 ++++++++ package.json | 24 + playwright.config.ts | 66 ++ tests/README.md | 101 +++ tests/auth.spec.ts | 176 +++++ tests/dashboard.spec.ts | 422 +++++++++++ tests/discounts.spec.ts | 545 +++++++++++++++ tests/helpers/allure.ts | 29 + tests/helpers/auth.ts | 81 +++ tests/helpers/environment.ts | 97 +++ tests/helpers/navigation.ts | 172 +++++ tests/management.spec.ts | 1206 ++++++++++++++++++++++++++++++++ tests/refunds.spec.ts | 836 ++++++++++++++++++++++ tests/roles.spec.ts | 182 +++++ tests/settings.spec.ts | 99 +++ tests/settlement.spec.ts | 346 +++++++++ tests/terminals.spec.ts | 762 ++++++++++++++++++++ tests/transactions.spec.ts | 854 ++++++++++++++++++++++ tsconfig.json | 27 + 23 files changed, 6568 insertions(+) create mode 100644 .env.test.example create mode 100644 .gitea/workflows/e2e-tests.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 tests/README.md create mode 100644 tests/auth.spec.ts create mode 100644 tests/dashboard.spec.ts create mode 100644 tests/discounts.spec.ts create mode 100644 tests/helpers/allure.ts create mode 100644 tests/helpers/auth.ts create mode 100644 tests/helpers/environment.ts create mode 100644 tests/helpers/navigation.ts create mode 100644 tests/management.spec.ts create mode 100644 tests/refunds.spec.ts create mode 100644 tests/roles.spec.ts create mode 100644 tests/settings.spec.ts create mode 100644 tests/settlement.spec.ts create mode 100644 tests/terminals.spec.ts create mode 100644 tests/transactions.spec.ts create mode 100644 tsconfig.json diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 0000000..f963524 --- /dev/null +++ b/.env.test.example @@ -0,0 +1,34 @@ +# Local e2e test credentials - copy this file to .env.test.local and fill in +# real values. .env.test.local is git-ignored (matches the `*.local` pattern +# in .gitignore) so real credentials never get committed. +# +# In CI, these same variable names are set directly from GitHub Secrets +# (see CI-CD-SETUP.md) - this file is only read for local runs. + +# Which environment to run against: develop | staging | main +# (also accepts dev | staging | production - same thing) +ENV=develop + +# Dev environment (used when ENV=develop/dev) - its own deployment, separate +# from staging. Falls back to TEST_USERNAME/TEST_PASSWORD below if unset. +DEV_URL= +DEV_USERNAME= +DEV_PASSWORD= + +# Older shared dev credentials - kept as a fallback for DEV_USERNAME/PASSWORD +# above and for staging (see below), matching how CI already provisions it. +TEST_USERNAME= +TEST_PASSWORD= + +# Staging credentials (optional - falls back to TEST_USERNAME/TEST_PASSWORD +# above if not set, matching how CI has always provisioned staging) +STAGING_TEST_USERNAME= +STAGING_TEST_PASSWORD= + +# Production credentials (required when ENV=main/production - kept separate +# on purpose so dev credentials can never accidentally run against prod) +PROD_TEST_USERNAME= +PROD_TEST_PASSWORD= + +# Optional: override the environment's default base URL entirely +# BASE_URL= diff --git a/.gitea/workflows/e2e-tests.yml b/.gitea/workflows/e2e-tests.yml new file mode 100644 index 0000000..2539e32 --- /dev/null +++ b/.gitea/workflows/e2e-tests.yml @@ -0,0 +1,92 @@ +name: E2E Tests + +# This repo holds ONLY the automated test suite - it has no application code +# and nothing to build or deploy. So unlike the app repo (where the workflow +# derived ENV from github.ref_name), the environment under test is an explicit +# input here: this repo's `main` branch tests whichever deployment you point +# it at, not "the deployment matching this branch". +on: + workflow_dispatch: + inputs: + environment: + description: 'Environment to test' + required: true + default: 'develop' + type: choice + options: + - develop + - staging + - main + schedule: + # Nightly regression run against develop (02:00 UTC = 05:00 UTC+3). + - cron: '0 2 * * *' + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 18 + cache: npm + + - run: npm ci + + - run: npx playwright install --with-deps chromium + + - name: Determine environment + id: env + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + ENV="${{ github.event.inputs.environment }}" + else + # Scheduled runs and pushes/PRs to this repo's main always + # exercise develop - the test suite's own default target. + ENV="develop" + fi + echo "ENVIRONMENT=$ENV" >> $GITHUB_OUTPUT + echo "Testing on: $ENV" + + # Credential selection per environment (dev/staging/production) is + # resolved centrally in tests/helpers/environment.ts, not here - all + # secrets are just passed through and the code picks the right pair + # based on ENV. This also means ENV=main correctly maps to production + # credentials/URL. + - run: npx playwright test --project=chromium --workers=1 + env: + ENV: ${{ steps.env.outputs.ENVIRONMENT }} + DEV_URL: ${{ secrets.DEV_URL }} + DEV_USERNAME: ${{ secrets.DEV_USERNAME }} + DEV_PASSWORD: ${{ secrets.DEV_PASSWORD }} + TEST_USERNAME: ${{ secrets.TEST_USERNAME }} + TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }} + STAGING_TEST_USERNAME: ${{ secrets.STAGING_TEST_USERNAME }} + STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }} + PROD_TEST_USERNAME: ${{ secrets.PROD_TEST_USERNAME }} + PROD_TEST_PASSWORD: ${{ secrets.PROD_TEST_PASSWORD }} + + # Reports are the whole point of a dedicated test repo, so keep them + # as artifacts instead of deleting them the way the app repo did. + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v3 + with: + name: playwright-report-${{ steps.env.outputs.ENVIRONMENT }} + path: playwright-report/ + retention-days: 14 + + - name: Upload Allure results + if: always() + uses: actions/upload-artifact@v3 + with: + name: allure-results-${{ steps.env.outputs.ENVIRONMENT }} + path: allure-results/ + retention-days: 14 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..210989f --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +node_modules + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +pnpm-debug.log* + +# Local credentials - .env.test.local is matched by the *.local pattern below +# and must never be committed. Copy .env.test.example to create it. +*.local +*.pem + +# Editor directories and files +.vscode +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.sw? + +# Test artifacts +allure-results/ +allure-report/ +playwright-report/ +test-results/ +downloads/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a215cfa --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# Frontend Automation + +Playwright end-to-end test suite for the **PassDashboard** merchant portal +(NeoPass), extracted into its own repository so it can be run and deployed +independently of the application. + +The tests drive the real, deployed application over HTTP — there is no +application code, build step, or local dev server in this repo. That is what +makes standalone operation possible: point the suite at a URL, give it +credentials, and run. + +> The Jest unit tests under `src/__tests__/` in the app repo are **not** part +> of this suite. They import application source directly (`AuthContext`, +> `ProtectedRoute`, `cryptoUtils`, `api`) and therefore have to live alongside +> that source. They remain in the PassDashboard repo. + +## Quick start + +```bash +npm ci +npx playwright install --with-deps chromium + +cp .env.test.example .env.test.local # then fill in real credentials +npm run test:e2e:dev +``` + +## Environments + +Environment and credentials are resolved in one place — +[tests/helpers/environment.ts](tests/helpers/environment.ts). It reads an `ENV` +variable and picks the matching base URL and credential pair. Both +[playwright.config.ts](playwright.config.ts) and +[tests/helpers/auth.ts](tests/helpers/auth.ts) read through it, so adding or +changing an environment is a one-file edit. + +`ENV` accepts either branch-style names (`develop` / `staging` / `main`) or +semantic names (`dev` / `staging` / `production`) — they map to the same thing. + +| Command | Target | +| --- | --- | +| `npm run test:e2e:dev` | `DEV_URL` (falls back to the shared staging URL) | +| `npm run test:e2e:staging` | `https://stagingenv.babinnovations.com` | +| `npm run test:e2e:prod` | `https://www.babinnovations.com/neopaas/portal` | +| `npm run test:e2e:ui` | Playwright UI mode, using `ENV` / `.env.test.local` as-is | + +Set `BASE_URL` to override the resolved URL entirely. + +## Running a subset + +```bash +npx playwright test tests/refunds.spec.ts +npx playwright test -g "should paginate through transaction list" +npx playwright test tests/dashboard.spec.ts --headed +npm run report # open the last HTML report +``` + +## Credentials + +`.env.test.local` is git-ignored via the `*.local` pattern and is only read for +local runs. CI never uses it — the workflow sets the same variable names from +Gitea Actions secrets. See [.env.test.example](.env.test.example) for the full +list and the fallback behaviour. + +Required secrets in **Settings → Actions → Secrets** for CI: + +| Secret | Needed for | +| --- | --- | +| `DEV_URL`, `DEV_USERNAME`, `DEV_PASSWORD` | `ENV=develop` | +| `TEST_USERNAME`, `TEST_PASSWORD` | shared fallback for dev and staging | +| `STAGING_TEST_USERNAME`, `STAGING_TEST_PASSWORD` | `ENV=staging` (optional; falls back to `TEST_*`) | +| `PROD_TEST_USERNAME`, `PROD_TEST_PASSWORD` | `ENV=main` — required, never falls back | + +Production credentials are deliberately kept separate so a dev credential pair +can never accidentally run against production. + +## CI + +[.gitea/workflows/e2e-tests.yml](.gitea/workflows/e2e-tests.yml) runs on Gitea +Actions. Because this repo has no per-environment branches, the target is an +explicit `workflow_dispatch` input rather than being derived from the branch +name; scheduled and push runs default to `develop`. The Playwright HTML report +and Allure results are uploaded as build artifacts. + +Requires a Gitea Actions runner with the `ubuntu-latest` label. If your runner +uses a different label, change `runs-on` in the workflow. + +## Suite layout + +See [tests/README.md](tests/README.md) for the per-spec breakdown, the helper +modules, and the list of known gaps / intentionally uncovered areas. + +**When adding a spec file:** `playwright.config.ts` uses an explicit +`testMatch` allowlist, not a wildcard glob. A new spec file that isn't added to +that list will silently never run. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cab1ed3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,295 @@ +{ + "name": "frontend-automation", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend-automation", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.44.0", + "@types/node": "^20.14.0", + "allure-playwright": "^3.10.0", + "typescript": "^5.5.3", + "xlsx": "^0.18.5" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/allure-js-commons": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/allure-js-commons/-/allure-js-commons-3.10.2.tgz", + "integrity": "sha512-5nAjUF1iNPSQMwKtEsadclSta8C3L705/k/pIzGb9M6P9krgfRaCyaEHt8pkLlCP9NFj5xk3grjtnAhiLeT+Kg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "md5": "^2.3.0" + }, + "peerDependencies": { + "allure-playwright": "3.10.2" + }, + "peerDependenciesMeta": { + "allure-playwright": { + "optional": true + } + } + }, + "node_modules/allure-playwright": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/allure-playwright/-/allure-playwright-3.10.2.tgz", + "integrity": "sha512-CGyhxSvx2bIkojhBy0pbQZ12vkLbOE3FBh39WgzPs+katwnuGPUE0whJhryG8aD/dlDALxMehL9G0k8WYBx4tQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "allure-js-commons": "3.10.2" + }, + "peerDependencies": { + "@playwright/test": ">=1.53.0" + } + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..64044e0 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "frontend-automation", + "private": true, + "version": "1.0.0", + "description": "Playwright end-to-end test suite for the PassDashboard merchant portal", + "type": "module", + "scripts": { + "test:e2e": "playwright test", + "test:e2e:dev": "ENV=develop playwright test", + "test:e2e:staging": "ENV=staging playwright test", + "test:e2e:prod": "ENV=production playwright test", + "test:e2e:ui": "playwright test --ui", + "typecheck": "tsc --noEmit", + "report": "playwright show-report", + "install:browsers": "playwright install --with-deps chromium" + }, + "devDependencies": { + "@playwright/test": "^1.44.0", + "@types/node": "^20.14.0", + "allure-playwright": "^3.10.0", + "typescript": "^5.5.3", + "xlsx": "^0.18.5" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..3c57164 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,66 @@ +import { defineConfig, devices } from '@playwright/test'; +import { getEnvironmentConfig } from './tests/helpers/environment'; + +/** + * Playwright configuration for PassDashboard test automation + * @see https://playwright.dev/docs/test-configuration + * + * Environment (dev/staging/production) and credentials are resolved once, + * centrally, in tests/helpers/environment.ts - see that file and + * .env.test.example for how to switch environments locally or in CI. + */ +const env = getEnvironmentConfig(); +console.log(`[playwright.config] Running against ${env.envName} (${env.baseURL})`); + +export default defineConfig({ + testDir: './tests', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: 2, + workers: 1, + reporter: [ + ['html'], + ['allure-playwright', { + outputFolder: 'allure-results', + detail: true, + suiteTitle: true, + }], + ], + use: { + baseURL: env.baseURL, + trace: 'off', + screenshot: 'off', + video: 'off', + actionTimeout: 30000, + navigationTimeout: 90000, + // Pin the browser clock to the platform's market timezone (UTC+3). + // The report date range is timezone-sensitive: picking 2026-07-31 on a + // UTC machine made the app request 2026-08-01 instead, which comes back + // 204 No Content, so no file ever downloads. CI runners are UTC while + // developers here run UTC+3, which is exactly why these download tests + // passed locally and failed only in CI. Pinning it makes both agree and + // matches how the product is actually used. + timezoneId: 'Asia/Riyadh', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + // Run terminals before management so removed cashier frees up a terminal + testMatch: [ + '**/auth.spec.ts', + '**/dashboard.spec.ts', + '**/terminals.spec.ts', + '**/transactions.spec.ts', + '**/discounts.spec.ts', + '**/refunds.spec.ts', + '**/settlement.spec.ts', + '**/management.spec.ts', + '**/settings.spec.ts', + '**/roles.spec.ts', + ], + }, + + ], +}); diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..ae78def --- /dev/null +++ b/tests/README.md @@ -0,0 +1,101 @@ +# E2E Test Suite + +Playwright end-to-end tests for the PassDashboard merchant portal. These +tests drive the real, deployed application over HTTP - they don't run +against a local build - so this folder can be copied into any checkout of +the repo (even a newer one) and will keep working as long as the app's URLs +and selectors haven't changed. + +## Folder structure + +``` +tests/ + helpers/ + environment.ts # single source of truth for env + credential resolution + auth.ts # login()/loginAs() - reads TEST_CREDENTIALS from environment.ts + navigation.ts # navigateToX(page) helpers for each portal page + allure.ts # thin wrappers around Allure annotations (epic/feature/severity/tag) + auth.spec.ts # login, invalid credentials, required-field validation, logout + dashboard.spec.ts # stat cards, dropdown filter, date-range download, weekly sales chart + transactions.spec.ts # search/filter, date range, details, export, pagination + terminals.spec.ts # details, pagination, download, cashier remove/reassign, add terminal + refunds.spec.ts # search/filter, date range, details, export, pagination + discounts.spec.ts # cross-checks Transactions-page totals against the Discounts page + settlement.spec.ts # download -> format -> upload -> run settlement workflow + management.spec.ts # Users/Branches tabs: add cashier/finance user, add branch, reports + settings.spec.ts # Discount Rate Configuration (Settings > Discount rates tab) + roles.spec.ts # creates Cashier/Finance/Terminal Manager users, logs in as each, + # verifies their actual restricted navigation menu +``` + +Each spec file corresponds to one page/feature area of the portal. Shared +logic (login, navigation, environment/credential resolution) lives in +`helpers/` so spec files stay focused on the feature they're testing. + +## Running against dev / staging / production + +Environment and credentials are resolved once, centrally, in +`helpers/environment.ts`. It reads an `ENV` variable (accepts branch names +`develop`/`staging`/`main` - what CI naturally has on hand - or semantic +names `dev`/`staging`/`production`) and picks the matching base URL and +credential pair. `playwright.config.ts` and `helpers/auth.ts` both import +from this one module, so there is nowhere else that needs updating to add +or change an environment. + +### Local setup (one-time) + +```bash +cp .env.test.example .env.test.local +# then fill in your real credentials in .env.test.local +``` + +`.env.test.local` is git-ignored (matches the `*.local` pattern already in +`.gitignore`) - it never gets committed, and CI doesn't use it at all (CI +sets the same variable names directly from GitHub Secrets - see +`CI-CD-SETUP.md`). + +### Running tests + +```bash +npm run test:e2e:dev # https://devpro.babinnovations.com +npm run test:e2e:staging # https://staging.babinnovations.com +npm run test:e2e:prod # https://www.babinnovations.com/neopaas/portal + +npm run test:e2e:ui # Playwright's interactive UI mode (uses ENV/.env.test.local as-is) + +# Run a single file or a single test by name, same as any Playwright project: +npx playwright test tests/refunds.spec.ts +npx playwright test -g "should paginate through transaction list" + +# Watch it run in a real browser window instead of headless: +npx playwright test tests/dashboard.spec.ts --headed +``` + +### Adding a new environment or changing a URL + +Edit `baseURLs` and/or `credentials` in `helpers/environment.ts` - that's +the only file that needs to change. Everything else (playwright.config.ts, +auth.ts, CI workflow) reads through it. + +## Adding a new spec file + +Playwright is configured with an explicit `testMatch` allowlist in +`playwright.config.ts` (not a wildcard glob) - **new spec files must be +added to that list** or they'll silently never run. This bit us once +already (`settings.spec.ts` and `roles.spec.ts` were invisible to the +runner until added). + +## Known gaps / intentionally not covered + +- **Reconciliation page** - out of scope for now +- **Bulk disable/enable** (Terminals page) - requires a genuinely active + physical POS terminal to succeed server-side; not reliably reproducible + in the dev environment, so it isn't automated +- **Terminals > Bulk Upload**, **Settings > Account/Response Timeout/ + Merchant Token/Trusted devices tabs**, **Dashboard notifications panel**, + **Arabic/RTL rendering**, **Forgot Password / 2FA** - never explored yet +- **Discount rate creation** (Settings > Discount rates > Create) - blocked + by a dev-environment data gap (the Card name dropdown has no options to + select), flagged as a product issue rather than fixed in the test +- A separate Jest unit-test layer exists under `src/__tests__/` - unrelated + to this Playwright suite and not covered by anything above diff --git a/tests/auth.spec.ts b/tests/auth.spec.ts new file mode 100644 index 0000000..a80274e --- /dev/null +++ b/tests/auth.spec.ts @@ -0,0 +1,176 @@ +import { test, expect } from '@playwright/test'; +import { TEST_CREDENTIALS } from './helpers/auth'; +import { epic, feature, severity, description } from './helpers/allure'; + +test.describe('Authentication Tests', () => { + test.setTimeout(90000); + test.beforeEach(async ({ page }) => { + epic('User Authentication'); + feature('Login'); + + // Retry the initial navigation in place - on a loaded local machine a + // single headless navigation can occasionally stall well past one + // timeout even though the server itself responds in ~1-3s. + 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); + + // Switch to English by clicking the article element + console.log('Checking for language switcher...'); + await page.waitForTimeout(1000); + + 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(); + console.log(`Found language button with text: "${text}"`); + console.log('Clicking to switch language...'); + await languageArticle.click(); + await page.waitForTimeout(2000); + console.log('✓ Language switched'); + } else { + console.log('Language button not found'); + } + }); + + test('should successfully login with valid credentials', async ({ page }) => { + severity('critical'); + description('Test successful login flow with valid user credentials'); + + console.log('Testing login with valid credentials...'); + + await test.step('Fill in username', async () => { + await page.fill('input[type="email"], input[name="email"], input[placeholder*="email" i]', TEST_CREDENTIALS.username); + }); + + await test.step('Fill in password', async () => { + await page.fill('input[type="password"], input[name="password"]', TEST_CREDENTIALS.password); + }); + + await test.step('Click login button', async () => { + await page.click('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")'); + }); + + await test.step('Verify redirect to dashboard', async () => { + await page.waitForURL('**/dashboard**', { timeout: 30000 }).catch(() => { + console.log('Checking for successful navigation...'); + }); + await page.waitForLoadState('domcontentloaded').catch(() => {}); + await page.waitForTimeout(2000); + + const currentUrl = page.url(); + console.log('Current URL after login:', currentUrl); + expect(currentUrl).toContain('dashboard'); + }); + + console.log('Login successful'); + }); + + test('should show error with invalid credentials', async ({ page }) => { + severity('critical'); + description('Verify error handling for invalid login credentials'); + + console.log('Testing login with invalid credentials...'); + + await test.step('Fill in invalid username', async () => { + await page.fill('input[type="email"], input[name="email"], input[placeholder*="email" i]', 'invalid@example.com'); + }); + + await test.step('Fill in invalid password', async () => { + await page.fill('input[type="password"], input[name="password"]', 'wrongpassword'); + }); + + await test.step('Click login button', async () => { + await page.click('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")'); + }); + + await test.step('Verify error message appears', async () => { + await page.waitForTimeout(2000); + + const errorMessage = await page.locator('text=/error|invalid|incorrect|failed/i').first().isVisible().catch(() => false); + const stillOnLoginPage = page.url().includes('login'); + + console.log('Error message visible:', errorMessage); + console.log('Still on login page:', stillOnLoginPage); + + expect(errorMessage || stillOnLoginPage).toBeTruthy(); + }); + + console.log('Invalid credentials test completed'); + }); + + test('should validate required fields', async ({ page }) => { + severity('normal'); + description('Verify that required field validation works correctly'); + + console.log('Testing required field validation...'); + + await test.step('Click login without filling fields', async () => { + await page.click('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")'); + await page.waitForTimeout(1000); + }); + + await test.step('Verify validation or staying on login page', async () => { + const stillOnLoginPage = page.url().includes('login'); + console.log('Still on login page:', stillOnLoginPage); + expect(stillOnLoginPage).toBeTruthy(); + }); + + console.log('Required field validation test completed'); + }); + + test('should successfully logout', async ({ page }) => { + test.setTimeout(90000); + severity('critical'); + description('Verify that clicking Logout redirects to login page and clears session'); + + // First login + await test.step('Login first', async () => { + await page.fill('input[type="email"], input[name="email"], input[placeholder*="email" i]', TEST_CREDENTIALS.username); + await page.fill('input[type="password"], input[name="password"]', TEST_CREDENTIALS.password); + await page.click('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")'); + await page.waitForURL('**/dashboard**', { timeout: 30000 }).catch(() => {}); + await page.waitForLoadState('domcontentloaded').catch(() => {}); + console.log('✓ Logged in, URL:', page.url()); + }); + + await test.step('Click Logout', async () => { + const logoutLink = page.locator('text=Logout').first(); + + if (!(await logoutLink.isVisible({ timeout: 2500 }).catch(() => false))) { + // Mobile layout might hide logout under a menu; try opening it. + const openMenu = page.locator('button[aria-label="Open menu"], button[aria-label="Open navigation"], button:has-text("Menu"), button:has-text("Open")'); + if (await openMenu.isVisible({ timeout: 2500 }).catch(() => false)) { + await openMenu.click(); + await page.waitForTimeout(1000); + } + } + + await logoutLink.waitFor({ state: 'visible', timeout: 10000 }); + await logoutLink.click(); + await page.waitForTimeout(3000); + console.log('✓ Logout clicked'); + }); + + await test.step('Verify redirected to login page', async () => { + await page.waitForURL('**/login**', { timeout: 10000 }).catch(() => {}); + const currentUrl = page.url(); + console.log('URL after logout:', currentUrl); + expect(currentUrl).toContain('login'); + }); + + console.log('✓ Logout test completed'); + }); +}); diff --git a/tests/dashboard.spec.ts b/tests/dashboard.spec.ts new file mode 100644 index 0000000..2ad9234 --- /dev/null +++ b/tests/dashboard.spec.ts @@ -0,0 +1,422 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; +import { epic, feature, severity, description, tag } from './helpers/allure'; +import { navigateToTransactions } from './helpers/navigation'; +import * as fs from 'fs'; + +// Parses either an ISO picker-cell title (YYYY-MM-DD) or a display-format +// table date ("Jul 6, 2026") into a comparable Date. +const toDate = (s: string | null | undefined): Date | null => { + if (!s) return null; + const iso = s.match(/(\d{4})-(\d{2})-(\d{2})/); + if (iso) return new Date(`${iso[1]}-${iso[2]}-${iso[3]}T00:00:00`); + const disp = s.match(/(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2}),\s*(\d{4})/); + if (disp) { + const months: Record = { + Jan: '01', Feb: '02', Mar: '03', Apr: '04', May: '05', Jun: '06', + Jul: '07', Aug: '08', Sep: '09', Oct: '10', Nov: '11', Dec: '12', + }; + return new Date(`${disp[3]}-${months[disp[1]]}-${disp[2].padStart(2, '0')}T00:00:00`); + } + return null; +}; + +const toISO = (d: Date): string => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + +test.describe('Dashboard Tests', () => { + test.setTimeout(90000); + test.beforeEach(async ({ page }) => { + epic('Dashboard'); + await login(page); + await page.waitForTimeout(2000); + }); + + // Functional Tests + test('should use dropdown filter and verify data updates', async ({ page }) => { + feature('Dashboard Operations'); + tag('functional'); + severity('critical'); + description('Test using the branch dropdown filter changes the dashboard\'s selected branch and data'); + + console.log('Testing dropdown filter functionality...'); + + // The real filter is the "All branches" Ant Design select at the top of + // the dashboard - generic `select, [role="combobox"]` selectors don't + // reliably target it, and this test previously had no assertions at all. + const branchSelect = page.locator('.ant-select').first(); + await expect(branchSelect, 'Branch dropdown should be visible').toBeVisible({ timeout: 10000 }); + + const initialSelection = (await branchSelect.textContent()) || ''; + console.log('Initial branch selection:', initialSelection.trim()); + + await test.step('Open dropdown and select a specific branch', async () => { + await branchSelect.click(); + await page.waitForTimeout(1000); + + const options = page.locator('.ant-select-item-option'); + const optionCount = await options.count(); + console.log('Available options:', optionCount); + expect(optionCount, 'There should be more than one branch option').toBeGreaterThan(1); + + // Pick a real named branch, not "All branches"/"-1" itself + const namedOption = options.filter({ hasNotText: /^All branches$/i }).first(); + const optionText = (await namedOption.textContent()) || ''; + await namedOption.click(); + await page.waitForTimeout(2000); + console.log('Selected option:', optionText.trim()); + }); + + await test.step('Verify the dropdown reflects the new selection', async () => { + const updatedSelection = (await branchSelect.textContent()) || ''; + console.log('Updated branch selection:', updatedSelection.trim()); + expect(updatedSelection.trim(), 'Dropdown display should change after selecting a different branch').not.toBe(initialSelection.trim()); + }); + + console.log('Dropdown filter test completed'); + }); + + test('should select date range and download report with transactions', async ({ page }) => { + test.setTimeout(90000); + feature('Dashboard Operations'); + tag('functional'); + severity('critical'); + description('Download a transaction report for the latest transaction date and verify the file contents'); + + console.log('Testing date range selection and download report...'); + + let targetIso = ''; + let latestDateObj: Date | null = null; + + await test.step('Determine target date from the latest transaction', async () => { + // The Dashboard page itself has no data table to read a date from, so + // briefly check the Transactions page - same anchoring approach used + // elsewhere, since a hardcoded date ("March 2026") eventually falls + // outside the calendar's reachable range as real time moves on. + await navigateToTransactions(page); + await page.waitForTimeout(2000); + const table = page.locator('table').filter({ has: page.locator('th:has-text("RRN")') }).first(); + await table.locator('tbody tr.ant-table-row').first().waitFor({ state: 'visible', timeout: 15000 }); + const headers = await table.locator('thead th').allTextContents(); + const dateIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'init date'); + expect(dateIdx, 'An init date column should exist').toBeGreaterThanOrEqual(0); + + const firstRowCells = await table.locator('tbody tr.ant-table-row').first().locator('td').allTextContents(); + const latestDate = toDate(firstRowCells[dateIdx] || ''); + expect(latestDate, 'The newest transaction should have a parseable date').not.toBeNull(); + latestDateObj = latestDate; + targetIso = toISO(latestDate!); + console.log(`Target date from latest transaction (${firstRowCells[dateIdx]}): ${targetIso}`); + + const dashboardNav = page.locator('button:has-text("Dashboard")').first(); + await dashboardNav.click(); + await page.waitForTimeout(2000); + }); + + await test.step('Open Download report modal', async () => { + const downloadButton = page.locator('button:has-text("Download report")').first(); + await expect(downloadButton, 'Download report button should be visible').toBeVisible({ timeout: 10000 }); + await downloadButton.click(); + await page.waitForTimeout(1500); + console.log('✓ Download modal opened'); + }); + + // Picks `iso` as a same-day range in the modal's date picker. Reusable so + // the download step can retry with an earlier date when the first one + // turns out to have no transactions to report on. + const selectSameDayRange = async (iso: string): Promise => { + const dateField = page.locator('[role="dialog"] .ant-picker input[date-range="start"], .ant-modal .ant-picker input[date-range="start"]').first(); + await expect(dateField, 'Date range start input should be visible').toBeVisible({ timeout: 10000 }); + await dateField.click(); + await page.waitForTimeout(1500); + + const calendarVisible = await page.locator('.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)').isVisible().catch(() => false); + expect(calendarVisible, 'Date range calendar should open').toBe(true); + + const prevBtn = page.locator('.ant-picker-header-prev-btn').first(); + let found = false; + for (let attempt = 0; attempt < 60; attempt++) { + found = await page.locator(`.ant-picker-cell-in-view[title="${iso}"]:not(.ant-picker-cell-disabled)`).first().isVisible().catch(() => false); + if (found) break; + await prevBtn.click(); + await page.waitForTimeout(300); + } + expect(found, `Target date ${iso} should become available in the calendar`).toBe(true); + + await page.locator(`.ant-picker-cell-in-view[title="${iso}"]`).first().click(); + await page.waitForTimeout(800); + await page.locator(`.ant-picker-cell-in-view[title="${iso}"]`).first().click(); + await page.waitForTimeout(1000); + console.log(`✓ Date selected: ${iso}`); + + // innerText() on the dialog doesn't capture values, so if the + // download never fires we can't otherwise tell whether the date range + // actually committed to the form - log the real input values and + // whether the calendar dropdown is still open (which would mean the + // range was never confirmed/closed). + const startVal = await page.locator('[role="dialog"] .ant-picker input[date-range="start"], .ant-modal .ant-picker input[date-range="start"]').first().inputValue().catch(() => '(unreadable)'); + const endVal = await page.locator('[role="dialog"] .ant-picker input[date-range="end"], .ant-modal .ant-picker input[date-range="end"]').first().inputValue().catch(() => '(unreadable)'); + const calendarStillOpen = await page.locator('.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)').isVisible().catch(() => false); + console.log(`Date range input values -> start: "${startVal}", end: "${endVal}". Calendar still open: ${calendarStillOpen}`); + }; + + await test.step('Select the target date (same-day range)', async () => { + await selectSameDayRange(targetIso); + }); + + await test.step('Select CSV format', async () => { + const csvRadio = page.locator('input.ant-radio-input[value="2"]').first(); + if (await csvRadio.isVisible({ timeout: 3000 }).catch(() => false)) { + await csvRadio.click({ force: true }); + await page.waitForTimeout(500); + console.log('✓ CSV selected via radio'); + } else { + const csvText = page.locator('text=CSV').first(); + await expect(csvText, 'CSV option should be available').toBeVisible({ timeout: 5000 }); + await csvText.click(); + await page.waitForTimeout(500); + console.log('✓ CSV selected via text'); + } + }); + + await test.step('Download and verify the file', async () => { + // Large result sets switch to an async email-delivery flow instead of + // a direct browser download (confirmed behavior of this same report + // feature elsewhere) - a single day is usually small enough to + // download directly, but treat the async message as a legitimate + // outcome too, not a failure. + // Record the report API traffic this click triggers - when neither a + // download nor the async message shows up, the response status is the + // only thing that says whether the request was even made, and how the + // backend answered. + const apiCalls: string[] = []; + page.on('response', (r) => { + if (/report|download|export/i.test(r.url())) { + apiCalls.push(`${r.status()} ${r.request().method()} ${r.url().slice(0, 200)}`); + } + }); + + const modalDownloadButton = page.locator('[role="dialog"] button:has-text("Download report"), .ant-modal button:has-text("Download")').first(); + + const clickAndWait = async (): Promise => { + const downloadPromise = page.waitForEvent('download', { timeout: 45000 }).catch(() => null); + await expect(modalDownloadButton, 'Download button in modal should be visible').toBeVisible({ timeout: 5000 }); + const btnEnabled = await modalDownloadButton.isEnabled().catch(() => false); + await modalDownloadButton.click(); + console.log(`Download button clicked (enabled: ${btnEnabled}), waiting for download...`); + return downloadPromise; + }; + + let download = await clickAndWait(); + + // A single day can legitimately have no transactions to report on, in + // which case the backend answers 204 No Content and no file is ever + // produced. Rather than failing, walk back a few days and try again - + // any day with data proves the download works just as well. + for (let daysBack = 2; !download && daysBack <= 6 && latestDateObj; daysBack += 2) { + const fallbackIso = toISO(new Date( + latestDateObj.getFullYear(), latestDateObj.getMonth(), latestDateObj.getDate() - daysBack, + )); + console.log(`No file for the selected day (API: ${apiCalls.join(' | ') || 'none'}) - retrying ${daysBack} day(s) earlier: ${fallbackIso}`); + apiCalls.length = 0; + await selectSameDayRange(fallbackIso); + download = await clickAndWait(); + } + + if (!download) { + const asyncMessage = await page.locator('text=/being generated|download link via email/i').first().isVisible({ timeout: 8000 }).catch(() => false); + if (!asyncMessage) { + // Neither outcome fired - dump what's actually on screen so the + // next failure (if any) says why instead of just "false". + const dialogText = await page.locator('[role="dialog"], .ant-modal-content').first().innerText().catch(() => '(no dialog/modal found)'); + console.log('Neither download nor async message appeared. Dialog/modal content:', dialogText.slice(0, 500)); + console.log('Report-related API calls seen:', apiCalls.length ? apiCalls.join(' | ') : '(none)'); + const toastText = await page.locator('.ant-message, .ant-notification, [role="alert"]').allTextContents().catch(() => []); + console.log('Toast/notification text:', toastText.length ? toastText.join(' | ') : '(none)'); + + // 204 No Content means the backend had nothing to report for the + // requested range - there is no file to download, so asserting one + // would be wrong. Skip rather than fail, but log the request so a + // wrong requested range still stands out. + const noContent = apiCalls.find((c) => c.startsWith('204')); + test.skip(!!noContent, `Report API returned 204 No Content - nothing to download for the requested range (${noContent})`); + } + expect(asyncMessage, 'Either a direct download should fire, or the async email-report message should appear').toBe(true); + console.log('✓ Result set was too large for a direct download - async email-report flow triggered as expected'); + return; + } + + const fileName = download.suggestedFilename(); + console.log('✓ Download captured! File name:', fileName); + + const filePath = await download.path(); + expect(filePath, 'Downloaded file should have a local path').toBeTruthy(); + + const stats = fs.statSync(filePath!); + console.log('File size:', (stats.size / 1024).toFixed(2), 'KB'); + expect(stats.size, 'Downloaded file should have content').toBeGreaterThan(100); + + if (/\.(csv|txt)$/i.test(fileName)) { + const fileContent = fs.readFileSync(filePath!, 'utf-8'); + console.log('File content preview:', fileContent.substring(0, 300)); + const hasTransactionData = /transaction|amount|date|payment|id/i.test(fileContent); + expect(hasTransactionData, `Downloaded file should contain transaction data for ${targetIso}`).toBe(true); + } else { + console.log(`Binary file format (${fileName}), skipping text content check`); + } + }); + + console.log('Download report test completed'); + }); + + test('should click on dashboard card and navigate', async ({ page }) => { + feature('Dashboard Operations'); + tag('functional'); + severity('critical'); + description('Test clicking the Purchase Transactions dashboard card navigates to the Transactions page'); + + console.log('Testing clickable dashboard cards...'); + + // The real dashboard stat cards don't use "card"/"widget"/"stat" class + // names (a `[class*="card" i]` selector matches zero elements) - they're + // plain divs styled with Tailwind's `cursor-pointer` utility class. + const purchaseCard = page.locator('article:has-text("Purchase Transactions")').locator('xpath=ancestor::div[contains(@class, "cursor-pointer")]').first(); + await expect(purchaseCard, 'Purchase Transactions card should be visible').toBeVisible({ timeout: 10000 }); + + const urlBefore = page.url(); + console.log('URL before click:', urlBefore); + + await test.step('Click the Purchase Transactions card', async () => { + await purchaseCard.click(); + await page.waitForTimeout(2000); + }); + + await test.step('Verify navigation to Transactions occurred', async () => { + const urlAfter = page.url(); + console.log('URL after click:', urlAfter); + expect(urlAfter, 'Clicking the card should navigate away from the dashboard').not.toBe(urlBefore); + expect(urlAfter, 'Clicking the Purchase Transactions card should navigate to Transactions').toContain('/transactions'); + }); + + console.log('Clickable card test completed'); + }); + + test('should interact with multiple dashboard cards', async ({ page }) => { + feature('Dashboard Operations'); + tag('functional'); + severity('normal'); + description('Test each main dashboard stat card has real content and navigates away when clicked'); + + console.log('Testing multiple dashboard cards...'); + + // The 3 real stat cards on this dashboard, confirmed live: Purchase + // Transactions, Refunds, Success Rate. Generic "[class*=card]"-style + // selectors match nothing (these are plain cursor-pointer divs), so we + // target them by their known labels instead. + const cardLabels = ['Purchase Transactions', 'Refunds', 'Success Rate']; + let cardsInteracted = 0; + + for (const label of cardLabels) { + await test.step(`Verify and click "${label}" card`, async () => { + const card = page.locator(`article:has-text("${label}")`).locator('xpath=ancestor::div[contains(@class, "cursor-pointer")]').first(); + await expect(card, `${label} card should be visible`).toBeVisible({ timeout: 10000 }); + + const cardText = (await card.textContent()) || ''; + console.log(`${label} card content:`, cardText.trim().substring(0, 80)); + expect(cardText.trim().length, `${label} card should have real content`).toBeGreaterThan(label.length); + + const urlBefore = page.url(); + await card.click(); + await page.waitForTimeout(1500); + const urlAfter = page.url(); + console.log(`${label}: ${urlBefore} -> ${urlAfter}`); + expect(urlAfter, `Clicking ${label} should navigate away from the dashboard`).not.toBe(urlBefore); + cardsInteracted++; + + // Back to the dashboard for the next card + const dashboardNav = page.locator('button:has-text("Dashboard")').first(); + await dashboardNav.click(); + await page.waitForTimeout(1500); + }); + } + + expect(cardsInteracted, 'All 3 known dashboard cards should have been interacted with').toBe(cardLabels.length); + console.log('Multiple cards test completed'); + }); + + test('should verify dropdown filter options are available', async ({ page }) => { + feature('Dashboard Operations'); + tag('functional'); + severity('normal'); + description('Test that the branch dropdown filter has multiple real branch options available'); + + console.log('Testing dropdown filter options...'); + + const branchSelect = page.locator('.ant-select').first(); + await expect(branchSelect, 'Branch dropdown should be visible').toBeVisible({ timeout: 10000 }); + + await branchSelect.click(); + await page.waitForTimeout(1000); + + const options = page.locator('.ant-select-item-option'); + const optionCount = await options.count(); + console.log('Dropdown options count:', optionCount); + expect(optionCount, 'There should be more than one branch option').toBeGreaterThan(1); + + for (let i = 0; i < Math.min(optionCount, 5); i++) { + console.log(`Option ${i + 1}:`, (await options.nth(i).textContent())?.trim()); + } + + console.log('Dropdown options test completed'); + }); + + test('should display the Weekly sales chart with day labels', async ({ page }) => { + feature('Dashboard Operations'); + tag('functional'); + severity('normal'); + description('Verify the Weekly sales chart renders all 7 day labels and shows either real data or the correct empty state'); + + console.log('Testing Weekly sales chart...'); + + // The dashboard is slow to render on accounts with a large transaction + // history, so give the chart room to appear before asserting on it. + const chartHeading = page.locator('text="Weekly sales"').first(); + await expect(chartHeading, 'Weekly sales chart heading should be visible').toBeVisible({ timeout: 30000 }); + + // Scope to the chart's card container (the heading's ancestor panel) + const chartCard = chartHeading.locator('xpath=ancestor::div[contains(@class,"rounded-3xl")]').first(); + await expect(chartCard, 'Weekly sales chart card should be visible').toBeVisible({ timeout: 30000 }); + + // The card renders immediately with a "Loading..." placeholder while the + // chart data is still being fetched - the day labels only exist once + // that resolves, so wait it out before asserting on them. + await chartCard.locator('text=/^Loading\\.\\.\\.$/').first() + .waitFor({ state: 'hidden', timeout: 30000 }).catch(() => {}); + + await test.step('Verify all 7 day labels are present', async () => { + const days = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']; + for (const day of days) { + const dayLabel = chartCard.locator(`text="${day}"`).first(); + await expect(dayLabel, `Day label "${day}" should be visible`).toBeVisible({ timeout: 5000 }); + } + console.log('✓ All 7 day labels (MON-SUN) are visible'); + }); + + await test.step('Verify the chart shows either real data or the correct empty state', async () => { + const emptyStateText = chartCard.locator('text=/no transactions this week/i').first(); + const isEmpty = await emptyStateText.isVisible({ timeout: 3000 }).catch(() => false); + + if (isEmpty) { + console.log('✓ Chart is in the empty state - "No transactions this week" correctly shown'); + await expect(emptyStateText, 'Empty state message should be visible').toBeVisible(); + } else { + const svgElements = chartCard.locator('svg'); + const svgCount = await svgElements.count(); + console.log(`Chart is populated - found ${svgCount} SVG element(s) rendering the chart`); + expect(svgCount, 'A populated chart should render at least one SVG element').toBeGreaterThan(0); + } + }); + + console.log('✓ Weekly sales chart test completed'); + }); +}); \ No newline at end of file diff --git a/tests/discounts.spec.ts b/tests/discounts.spec.ts new file mode 100644 index 0000000..638a9ee --- /dev/null +++ b/tests/discounts.spec.ts @@ -0,0 +1,545 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; +import { epic, feature, severity, description, tag } from './helpers/allure'; +import { navigateToTransactions, navigateToDiscounts, navigateToSettings } from './helpers/navigation'; + +test.describe('Discounts & Fees Tests', () => { + test.setTimeout(300000); + + test('should calculate discount and verify against transactions page', async ({ page }) => { + feature('Discounts Operations'); + tag('functional'); + severity('critical'); + description('Get latest transaction date, calculate MDR+VAT+FEE from transactions, then verify matches Discounts page totals'); + epic('Discounts & Fees'); + + let latestDate = ''; + let txnTotalMDR = 0, txnTotalVAT = 0, txnTotalFee = 0, txnTotalDiscount = 0; + + // Step 1: Go to Transactions page + await test.step('Step 1: Go to Transactions page', async () => { + await login(page); + await navigateToTransactions(page); + await page.waitForTimeout(2000); + console.log('✓ On Transactions page'); + }); + + // Step 2: Find latest date with transactions + await test.step('Step 2: Find latest date with transactions', async () => { + await page.waitForTimeout(1000); + + // Scroll the table horizontally to reveal the init date column + const tableContent = page.locator('.ant-table-content').first(); + await tableContent.evaluate((element) => { + // Scroll to the right to reveal the init date column + element.scrollLeft = element.scrollWidth; + }); + await page.waitForTimeout(500); + console.log('✓ Scrolled table horizontally to reveal init date column'); + + // Get the first real data row (skip the hidden ant-table-measure-row) + const firstRow = page.locator('tr.ant-table-row').first(); + const rowText = await firstRow.textContent(); + + console.log('Full first row text after scroll:', rowText?.substring(0, 200)); + + // Extract date from the row (format: "Mon DD, YYYY" like "Jun 19, 2026") + const dateMatch = rowText?.match(/(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},\s*\d{4}/); + + if (dateMatch) { + latestDate = dateMatch[0]; + console.log('\n========== DATE EXTRACTED =========='); + console.log('✓ Latest transaction date:', latestDate); + console.log('====================================\n'); + } else { + console.log('⚠ Could not extract date from first row'); + throw new Error('Failed to extract date from transaction row'); + } + }); + + // Step 3: Count how many transaction rows fall within the latest date + await test.step('Step 3: Count transactions within the latest date', async () => { + await page.waitForTimeout(500); + + const monthRegex = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},\s*\d{4}/; + + let totalMatching = 0; + let currentPage = 1; + let keepPaging = true; + + while (keepPaging) { + // Wait for the table body to be present before reading + await page.locator('tr.ant-table-row').first().waitFor({ state: 'visible', timeout: 10000 }); + + const rows = page.locator('tr.ant-table-row'); + const rowCount = await rows.count(); + + // Capture the first row key on this page (used to detect page change later) + const firstKey = await rows.first().getAttribute('data-row-key'); + const lastKey = await rows.nth(rowCount - 1).getAttribute('data-row-key'); + + // Read the actual page footer ("Page X of Y") for verification + const footer = await page.locator('text=/Page\\s+\\d+\\s+of\\s+\\d+/').first().textContent().catch(() => null); + + let pageMatching = 0; + for (let i = 0; i < rowCount; i++) { + const rowText = await rows.nth(i).textContent(); + const match = rowText?.match(monthRegex); + const rowDate = match ? match[0] : ''; + if (rowDate === latestDate) { + pageMatching++; + } + } + + totalMatching += pageMatching; + console.log(`[${footer?.trim() || 'page ' + currentPage}] firstID=${firstKey} lastID=${lastKey} -> ${pageMatching}/${rowCount} on ${latestDate}`); + + // If not every row on this page matched, we've reached older dates - stop + if (pageMatching < rowCount) { + keepPaging = false; + break; + } + + // All rows matched - go to next page + const nextPage = currentPage + 1; + const nextPageBtn = page.locator(`button:has-text("${nextPage}")`).first(); + const hasNextPage = await nextPageBtn.isVisible({ timeout: 2000 }).catch(() => false); + + if (!hasNextPage) { + keepPaging = false; + break; + } + + await nextPageBtn.click(); + + // Wait until the first row's key actually changes (confirms the page re-rendered) + await page.waitForFunction( + (prevKey) => { + const row = document.querySelector('tr.ant-table-row'); + return row && row.getAttribute('data-row-key') !== prevKey; + }, + firstKey, + { timeout: 10000 } + ).catch(() => { + console.log('⚠ Page did not change after clicking next - stopping'); + }); + + currentPage = nextPage; + } + + console.log('\n========== ROW COUNT =========='); + console.log(`✓ Total transactions on ${latestDate}: ${totalMatching}`); + console.log('===============================\n'); + }); + + // Step 4: Enable MDR, VAT, Fee columns via Manage columns modal + await test.step('Step 4: Enable MDR, VAT, Fee columns', async () => { + // Open the Manage columns modal + const manageColumnsBtn = page.locator('button:has-text("Manage columns"), button:has-text("columns")').first(); + await manageColumnsBtn.click(); + await page.waitForTimeout(1000); + console.log('✓ Opened Manage columns modal'); + + // Scope everything to the modal dialog so we don't hit the page's own search/checkboxes + const modal = page.locator('.ant-modal-content').first(); + const searchInput = modal.locator('input[placeholder="Search"]').first(); + + // Helper: search for a column and enable its checkbox if not already checked + const enableColumn = async (term: string) => { + await searchInput.fill(''); + await page.waitForTimeout(300); + await searchInput.fill(term); + await page.waitForTimeout(700); + + // After filtering, the matching column's checkbox should be the visible one + const checkbox = modal.locator('input.ant-checkbox-input').first(); + const exists = await checkbox.isVisible({ timeout: 3000 }).catch(() => false); + + if (!exists) { + console.log(`⚠ No checkbox found for "${term}"`); + return; + } + + const isChecked = await checkbox.isChecked().catch(() => false); + if (isChecked) { + console.log(`• "${term}" column already enabled`); + } else { + await checkbox.click(); + await page.waitForTimeout(400); + console.log(`✓ Enabled "${term}" column`); + } + }; + + await enableColumn('mdr'); + await enableColumn('vat'); + await enableColumn('fee'); + + // Clear the search before closing + await searchInput.fill(''); + await page.waitForTimeout(300); + + // Click Done to apply + const doneBtn = modal.locator('button:has-text("Done")').first(); + await doneBtn.click(); + await page.waitForTimeout(1000); + console.log('✓ Applied column selection (clicked Done)'); + }); + + // Step 5: Sum MDR + VAT + Fee across all transactions on the latest date + await test.step('Step 5: Sum MDR + VAT + Fee for the latest date', async () => { + const monthRegex = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},\s*\d{4}/; + + // Go back to page 1 (confirm the table actually re-renders) + const beforeKey = await page.locator('tr.ant-table-row').first().getAttribute('data-row-key'); + const firstPageBtn = page.getByRole('button', { name: '1', exact: true }).first(); + if (await firstPageBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await firstPageBtn.scrollIntoViewIfNeeded().catch(() => {}); + await firstPageBtn.click(); + await page.waitForFunction( + (prevKey) => { + const r = document.querySelector('tr.ant-table-row'); + return r && r.getAttribute('data-row-key') !== prevKey; + }, + beforeKey, + { timeout: 10000 } + ).catch(() => console.log('⚠ Did not return to page 1 cleanly')); + await page.waitForTimeout(500); + } + console.log(`Back on page 1, first row ID: ${await page.locator('tr.ant-table-row').first().getAttribute('data-row-key')}`); + + // Determine column indexes for MDR, VAT, Fee from the table header + const headerCells = page.locator('thead th'); + const headerCount = await headerCells.count(); + let mdrIdx = -1, vatIdx = -1, feeIdx = -1; + for (let i = 0; i < headerCount; i++) { + const t = (await headerCells.nth(i).textContent())?.trim(); + if (t === 'MDR') mdrIdx = i; + else if (t === 'VAT') vatIdx = i; + else if (t === 'Fee') feeIdx = i; + } + console.log(`Column indexes -> MDR:${mdrIdx} VAT:${vatIdx} Fee:${feeIdx}`); + if (mdrIdx < 0 || vatIdx < 0 || feeIdx < 0) { + throw new Error('Could not locate MDR/VAT/Fee columns in header'); + } + + // Read a numeric value from a cell: the value sits in the last
, + // separate from the currency-icon SVG article. + const parseCell = async (cell: ReturnType): Promise => { + const valArticle = cell.locator('article').last(); + const txt = (await valArticle.textContent().catch(() => '')) || ''; + const num = parseFloat(txt.replace(/[^0-9.\-]/g, '')); + return isNaN(num) ? 0 : num; + }; + + let totalMDR = 0, totalVAT = 0, totalFee = 0, totalRows = 0; + let currentPage = 1; + let keepPaging = true; + + while (keepPaging) { + await page.locator('tr.ant-table-row').first().waitFor({ state: 'visible', timeout: 10000 }); + const rows = page.locator('tr.ant-table-row'); + const rowCount = await rows.count(); + const firstKey = await rows.first().getAttribute('data-row-key'); + + let pageMatching = 0; + for (let i = 0; i < rowCount; i++) { + const row = rows.nth(i); + const rowText = await row.textContent(); + const m = rowText?.match(monthRegex); + const rowDate = m ? m[0] : ''; + if (rowDate !== latestDate) continue; + + const cells = row.locator('td'); + const mdr = await parseCell(cells.nth(mdrIdx)); + const vat = await parseCell(cells.nth(vatIdx)); + const fee = await parseCell(cells.nth(feeIdx)); + const rowDiscount = mdr + vat + fee; + + totalMDR += mdr; + totalVAT += vat; + totalFee += fee; + totalRows++; + pageMatching++; + + const id = await row.getAttribute('data-row-key'); + console.log(` Row ${id}: MDR=${mdr.toFixed(2)} VAT=${vat.toFixed(2)} Fee=${fee.toFixed(2)} -> ${rowDiscount.toFixed(2)}`); + } + + console.log(`[page ${currentPage}] matched ${pageMatching}/${rowCount} rows`); + + // If not every row matched, we've reached older dates - stop + if (pageMatching < rowCount) { + keepPaging = false; + break; + } + + // Go to next page + const nextPage = currentPage + 1; + const nextPageBtn = page.getByRole('button', { name: `${nextPage}`, exact: true }).first(); + const hasNextPage = await nextPageBtn.isVisible({ timeout: 2000 }).catch(() => false); + if (!hasNextPage) { + keepPaging = false; + break; + } + + await nextPageBtn.click(); + await page.waitForFunction( + (prevKey) => { + const row = document.querySelector('tr.ant-table-row'); + return row && row.getAttribute('data-row-key') !== prevKey; + }, + firstKey, + { timeout: 10000 } + ).catch(() => { + console.log('⚠ Page did not change after clicking next - stopping'); + }); + currentPage = nextPage; + } + + const grandTotal = totalMDR + totalVAT + totalFee; + txnTotalMDR = totalMDR; + txnTotalVAT = totalVAT; + txnTotalFee = totalFee; + txnTotalDiscount = grandTotal; + console.log('\n========== DISCOUNT TOTALS (Transactions page) =========='); + console.log(`Date: ${latestDate}`); + console.log(`Rows summed: ${totalRows}`); + console.log(`Total MDR: ${totalMDR.toFixed(2)}`); + console.log(`Total VAT: ${totalVAT.toFixed(2)}`); + console.log(`Total Fee: ${totalFee.toFixed(2)}`); + console.log(`Total Discount (MDR+VAT+Fee): ${grandTotal.toFixed(2)}`); + console.log('=========================================================\n'); + }); + + // Step 6: Go to Discounts page and filter by the transaction date + await test.step('Step 6: Go to Discounts page and filter by date', async () => { + await navigateToDiscounts(page); + await page.waitForTimeout(2000); + console.log('✓ On Discounts & Fees page'); + + // Convert latestDate ("Jun 19, 2026") to picker title format ("2026-06-19") + const months: Record = { + Jan: '01', Feb: '02', Mar: '03', Apr: '04', May: '05', Jun: '06', + Jul: '07', Aug: '08', Sep: '09', Oct: '10', Nov: '11', Dec: '12', + }; + const parts = latestDate.match(/(\w+)\s+(\d{1,2}),\s*(\d{4})/); + if (!parts) { + throw new Error(`Could not parse latestDate: ${latestDate}`); + } + const titleDate = `${parts[3]}-${months[parts[1]]}-${parts[2].padStart(2, '0')}`; + console.log(`Target date cell title: ${titleDate}`); + + // Open the date range picker by clicking the start date input + const startInput = page.locator('input[placeholder="Start date"]').first(); + await startInput.click(); + await page.waitForTimeout(1000); + console.log('✓ Opened date range picker'); + + // The calendar opens on the current month - page back until the + // target month is in view (this dataset's dates can be well over a + // year old, so the calendar needs more than a couple of clicks back). + const dayCell = page.locator(`td[title="${titleDate}"]`).first(); + const prevBtn = page.locator('.ant-picker-header-prev-btn').first(); + let dayCellFound = false; + for (let attempt = 0; attempt < 60; attempt++) { + dayCellFound = await dayCell.isVisible().catch(() => false); + if (dayCellFound) break; + await prevBtn.click(); + await page.waitForTimeout(300); + } + expect(dayCellFound, `Target date ${titleDate} should become available in the calendar`).toBe(true); + + // Click the target day cell for the start of the range + await dayCell.click(); + await page.waitForTimeout(500); + console.log('✓ Selected start date'); + + // Click the same day cell again for the end of the range (single-day filter) + const dayCellEnd = page.locator(`td[title="${titleDate}"]`).first(); + await dayCellEnd.click(); + await page.waitForTimeout(1500); + console.log('✓ Selected end date (same day)'); + + // Verify the filter actually took: read back the start/end input values + const startVal = await page.locator('input[date-range="start"], input[placeholder="Start date"]').first().inputValue().catch(() => ''); + const endVal = await page.locator('input[date-range="end"], input[placeholder="End date"]').first().inputValue().catch(() => ''); + console.log(`Start input value: "${startVal}"`); + console.log(`End input value: "${endVal}"`); + + const pickerOpen = await page.locator('.ant-picker-dropdown:visible').count().catch(() => 0); + console.log(`Picker dropdown still open: ${pickerOpen > 0 ? 'yes' : 'no'}`); + + if (startVal === titleDate && endVal === titleDate) { + console.log(`✓ Date range correctly applied: ${titleDate} to ${titleDate}`); + } else { + console.log(`⚠ Date range may not have applied correctly (expected ${titleDate} for both)`); + } + + console.log(`✓ Filtered Discounts page to ${latestDate}`); + }); + + // Step 7: Read the Discounts-page total and cross-check against Transactions + await test.step('Step 7: Cross-check total discount', async () => { + await page.waitForTimeout(1500); + + const parseNum = (s: string | null | undefined): number => { + const n = parseFloat((s || '').replace(/[^0-9.\-]/g, '')); + return isNaN(n) ? 0 : n; + }; + + // Top "Total Discount" card (large number) - this is the source of truth + const totalDiscountCard = page.locator('article.text-4xl').first(); + const cardText = await totalDiscountCard.textContent().catch(() => ''); + const discountPageTotal = parseNum(cardText); + + // Scheme Discount Breakdown table (scoped via its heading) for internal detail + const schemeTable = page.locator('xpath=//*[contains(text(),"Scheme Discount Breakdown")]/following::table[1]').first(); + let breakdownMDR = 0, breakdownVAT = 0, breakdownFee = 0, breakdownRed = 0; + + if (await schemeTable.isVisible({ timeout: 3000 }).catch(() => false)) { + // Map header columns to indexes + const headers = schemeTable.locator('thead th'); + const headerCount = await headers.count(); + let mdrIdx = -1, vatIdx = -1, feeIdx = -1, dfIdx = -1; + for (let i = 0; i < headerCount; i++) { + const t = (await headers.nth(i).textContent())?.trim().toLowerCase(); + if (t === 'mdr') mdrIdx = i; + else if (t === 'vat') vatIdx = i; + else if (t?.includes('processing') || t === 'fee') feeIdx = i; + else if (t?.includes('discounts')) dfIdx = i; + } + + // Sum across all card-scheme rows. Each amount cell renders a + // currency-icon SVG (with an embedded