Files
Frontend_automation/tests/dashboard.spec.ts

422 lines
21 KiB
TypeScript
Raw Permalink Normal View History

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) <noreply@anthropic.com>
2026-08-03 15:50:50 +03:00
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<string, string> = {
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<void> => {
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 <input> 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<import('@playwright/test').Download | null> => {
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');
});
});