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

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

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

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

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

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

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

545
tests/discounts.spec.ts Normal file
View File

@@ -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 <article>,
// separate from the currency-icon SVG article.
const parseCell = async (cell: ReturnType<typeof page.locator>): Promise<number> => {
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<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',
};
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 <style>.cls-1{fill:#231f20}...)
// in one <article> and the actual number in a later <article> - reading
// the whole <td>'s text (like the naive version below did) drags the
// icon's hex-color digits into the string and corrupts the parsed
// number. Scope to the last <article>, same fix already used for the
// Transactions-page MDR/VAT/Fee columns above.
const readCellNum = async (row: ReturnType<typeof schemeTable.locator>, idx: number): Promise<number> => {
if (idx < 0) return 0;
const cell = row.locator('td').nth(idx);
const valArticle = cell.locator('article').last();
const txt = (await valArticle.textContent().catch(() => '')) || (await cell.textContent().catch(() => '')) || '';
return parseNum(txt);
};
const rows = schemeTable.locator('tbody tr:not([aria-hidden="true"])');
const rowCount = await rows.count();
for (let r = 0; r < rowCount; r++) {
const row = rows.nth(r);
breakdownMDR += await readCellNum(row, mdrIdx);
breakdownVAT += await readCellNum(row, vatIdx);
breakdownFee += await readCellNum(row, feeIdx);
breakdownRed += await readCellNum(row, dfIdx);
}
}
const breakdownSum = breakdownMDR + breakdownVAT + breakdownFee;
console.log('\n========== CROSS-CHECK ==========');
console.log(`Date: ${latestDate}`);
console.log(`Transactions page sum (rounded per-row MDR+VAT+Fee): ${txnTotalDiscount.toFixed(2)}`);
console.log(` - MDR: ${txnTotalMDR.toFixed(2)}, VAT: ${txnTotalVAT.toFixed(2)}, Fee: ${txnTotalFee.toFixed(2)}`);
console.log(`Discounts page Total Discount card: ${discountPageTotal.toFixed(2)}`);
console.log(`Discounts page breakdown -> MDR: ${breakdownMDR.toFixed(2)}, VAT: ${breakdownVAT.toFixed(2)}, Fee: ${breakdownFee.toFixed(2)} (sum ${breakdownSum.toFixed(2)})`);
console.log(`Discounts page breakdown "Discounts & fees" column: ${breakdownRed.toFixed(2)}`);
const info = test.info();
// NOTE: the "Total Discount" card is scoped to the selected date range,
// but the Scheme/Terminal Discount Breakdown tables show all-time
// cumulative totals regardless of the date filter (confirmed against
// the live Terminal Discount Breakdown, which shows values far larger
// than any single day could produce). So card vs breakdown-sum is NOT
// expected to match - this is informational only, not an assertion.
const internalDiff = Math.abs(discountPageTotal - breakdownSum);
if (internalDiff < 0.01) {
console.log(`Discounts page card and breakdown happen to match (${discountPageTotal.toFixed(2)}) - breakdown is all-time, card is date-filtered`);
} else {
console.log(`Discounts page card (date-filtered: ${discountPageTotal.toFixed(2)}) vs breakdown (all-time: ${breakdownSum.toFixed(2)}) - expected to differ, not a bug`);
}
// Cross-check Transactions sum vs Discounts total
const diff = Math.abs(txnTotalDiscount - discountPageTotal);
const summary =
`Date: ${latestDate}\n` +
`Transactions page sum (rounded per-row): ${txnTotalDiscount.toFixed(2)}\n` +
`Discounts page total: ${discountPageTotal.toFixed(2)}\n` +
`Difference: ${diff.toFixed(2)}`;
await info.attach('discount-cross-check', { body: summary, contentType: 'text/plain' });
if (diff < 0.01) {
console.log(`✓ MATCH: totals are equal (${discountPageTotal.toFixed(2)})`);
} else {
const msg = `Calculation mismatch on ${latestDate}: Transactions page sum = ${txnTotalDiscount.toFixed(2)}, Discounts page total = ${discountPageTotal.toFixed(2)} (difference ${diff.toFixed(2)})`;
console.log(`${msg}`);
info.annotations.push({ type: 'mismatch', description: msg });
// Surface as a soft failure so the Playwright report flags it without aborting
expect.soft(txnTotalDiscount, msg).toBeCloseTo(discountPageTotal, 2);
}
console.log('=================================\n');
});
// Step 8: Read configured discount rates from Settings (source of truth)
await test.step('Step 8: Read discount rates from Settings', async () => {
await navigateToSettings(page);
await page.waitForTimeout(2000);
console.log('✓ On Settings page');
// Click the "Discount rates" tab
const discountRatesTab = page.getByText('Discount rates', { exact: true }).first();
await discountRatesTab.click();
await page.waitForTimeout(1500);
console.log('✓ Opened Discount rates');
const parseNum = (s: string | null | undefined): number => {
const n = parseFloat((s || '').replace(/[^0-9.\-]/g, ''));
return isNaN(n) ? 0 : n;
};
// Scope to the Discount Rate Configuration table
const ratesTable = page.locator('xpath=//*[contains(text(),"Discount Rate Configuration")]/following::table[1]').first();
await ratesTable.waitFor({ state: 'visible', timeout: 10000 });
// Map header columns to indexes
const headers = ratesTable.locator('thead th');
const headerCount = await headers.count();
let cardIdx = -1, mdrIdx = -1, vatIdx = -1, capIdx = -1, feeIdx = -1;
for (let i = 0; i < headerCount; i++) {
const t = (await headers.nth(i).textContent())?.trim().toLowerCase();
if (t === 'card') cardIdx = i;
else if (t === 'mdr') mdrIdx = i;
else if (t === 'vat') vatIdx = i;
else if (t?.includes('cap')) capIdx = i;
else if (t?.includes('processing') || t === 'fee') feeIdx = i;
}
console.log(`Rate columns -> card:${cardIdx} mdr:${mdrIdx} vat:${vatIdx} cap:${capIdx} fee:${feeIdx}`);
const rateMap: Record<string, { mdr: number; vat: number; cap: number; fee: number }> = {};
const rows = ratesTable.locator('tbody tr');
const rowCount = await rows.count();
for (let r = 0; r < rowCount; r++) {
const cellTexts = await rows.nth(r).locator('td').allTextContents();
const cardText = cellTexts[cardIdx] || '';
// Extract the English card-scheme name after the "/"
const nameMatch = cardText.match(/\/\s*([A-Za-z]+)/);
const cardName = (nameMatch ? nameMatch[1] : cardText.trim()).toLowerCase();
const mdr = parseNum(cellTexts[mdrIdx]);
const vat = parseNum(cellTexts[vatIdx]);
const cap = capIdx >= 0 ? parseNum(cellTexts[capIdx]) : 0;
const fee = feeIdx >= 0 ? parseNum(cellTexts[feeIdx]) : 0;
rateMap[cardName] = { mdr, vat, cap, fee };
console.log(` ${cardName}: MDR ${mdr}% | VAT ${vat}% | Cap ${cap} | Fee ${fee}%`);
}
console.log('\n========== DISCOUNT RATES (Settings) ==========');
console.log(JSON.stringify(rateMap, null, 2));
console.log('===============================================\n');
});
});
});