diff --git a/bridge/reporting/__tests__/components/ReportTable.test.jsx b/bridge/reporting/__tests__/components/ReportTable.test.jsx new file mode 100644 index 0000000000..766bcf6ca5 --- /dev/null +++ b/bridge/reporting/__tests__/components/ReportTable.test.jsx @@ -0,0 +1,223 @@ +import ReportTable from '@/components/ReportTable'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, within } from '@testing-library/react'; + +const REQUEST_TAB = { + resource: 'requests', + sourceAsset: { ticker: 'XYM', divisibility: 6 }, + destinationAsset: { ticker: 'WXYM', divisibility: 6 }, + sourceNetwork: 'nativeNetwork', + destinationNetwork: 'wrappedNetwork' +}; + +const ERROR_TAB = { + ...REQUEST_TAB, + resource: 'errors' +}; + +const CONFIGURATION = { + nativeNetwork: { + blockchain: 'symbol', + explorerUrl: 'https://symbol.example' + }, + wrappedNetwork: { + blockchain: 'ethereum', + explorerUrl: 'https://ethereum.example' + } +}; + +const REQUEST_ROW = { + destinationAddress: '0x1f533cd9711049fA7604D0F49C45B6e5Af30ef8e', + errorMessage: null, + payoutConversionRate: '1000000', + payoutNetAmount: '299642570825', + payoutSentTimestamp: 4, + payoutStatus: 2, + payoutTimestamp: 3, + payoutTotalFee: '357429175', + payoutTransactionHash: 'A'.repeat(64), + payoutTransactionHeight: '11', + requestAmount: '300000000000', + requestTimestamp: 2, + requestTransactionHash: 'B'.repeat(64), + requestTransactionHeight: '10', + requestTransactionSubindex: -1, + senderAddress: 'TCONKG47FW2ZEZBPV6G7F422LXBDSMVT3JMYM4I' +}; + +const ERROR_ROW = { + errorMessage: 'Required message is missing', + requestTimestamp: 5, + requestTransactionHash: 'C'.repeat(64), + requestTransactionHeight: '13', + requestTransactionSubindex: -1, + senderAddress: 'TARDV42KTAIZEF64EQT4NXT7K55DHWBEFIXVJQY' +}; + +const renderTable = ({ + configuration = CONFIGURATION, + onSortChange = jest.fn(), + rows = [REQUEST_ROW], + sort = 0, + tab = REQUEST_TAB +} = {}) => { + const reportTable = ( + + ); + const result = render(reportTable); + + return { + ...result, + onSortChange, + table: screen.getByRole('table'), + mobileCard: screen.getByRole('article') + }; +}; + +describe('ReportTable', () => { + it('renders and formats request report fields', () => { + // Arrange: + const { table } = renderTable(); + + // Act: + const tableView = within(table); + + // Assert: + expect(tableView.getByText('Completed')).toBeInTheDocument(); + expect(tableView.getByText('TCONKG47F…JMYM4I')).toHaveAttribute('title', 'TCONKG47FW2ZEZBPV6G7F422LXBDSMVT3JMYM4I'); + expect(tableView.getByText('AAAAAAAA…AAAAAA')).toHaveAttribute('title', 'A'.repeat(64)); + expect(tableView.getByText('300000')).toBeInTheDocument(); + expect(tableView.getByText('0x1f533cd…30ef8e')).toHaveAttribute('title', '0x1f533cd9711049fA7604D0F49C45B6e5Af30ef8e'); + expect(tableView.getByText('BBBBBBBB…BBBBBB')).toHaveAttribute('title', 'B'.repeat(64)); + expect(tableView.getByText('1')).toHaveAttribute('title', '1000000 PPM'); + expect(tableView.getByText('357.429175')).toBeInTheDocument(); + expect(tableView.getByText('299642.570825')).toBeInTheDocument(); + expect(tableView.getByText('1970-01-01 00:00:02 UTC')).toBeInTheDocument(); + expect(tableView.getByText('1970-01-01 00:00:03 UTC')).toBeInTheDocument(); + }); + + it('renders the active sort direction and handles sort changes', () => { + // Arrange: + const onSortChange = jest.fn(); + const { table } = renderTable({ onSortChange }); + const sortButton = within(table).getByRole('button', { + name: 'Sort by request block height ascending' + }); + + // Act: + fireEvent.click(sortButton); + + // Assert: + expect(onSortChange).toHaveBeenCalledTimes(1); + expect(sortButton.closest('th')).toHaveAttribute('aria-sort', 'descending'); + }); + + const runDescendingSortTest = params => { + // Arrange: + const { table } = renderTable(params); + + // Act: + const sortButton = within(table).getByRole('button', { + name: 'Sort by request block height descending' + }); + + // Assert: + expect(sortButton).toHaveTextContent('↑'); + expect(sortButton.closest('th')).toHaveAttribute('aria-sort', 'ascending'); + }; + + it('renders the descending sort direction for request reports', () => { + runDescendingSortTest({ sort: 1 }); + }); + + it('renders the descending sort direction for error reports', () => { + runDescendingSortTest({ + rows: [ERROR_ROW], + sort: 1, + tab: ERROR_TAB + }); + }); + + it('renders and formats errors report fields', () => { + // Arrange: + const { table } = renderTable({ + rows: [ERROR_ROW], + tab: ERROR_TAB + }); + + // Act: + const tableView = within(table); + + // Assert: + expect(tableView.getByText('TARDV42KT…IXVJQY')).toHaveAttribute('title', 'TARDV42KTAIZEF64EQT4NXT7K55DHWBEFIXVJQY'); + expect(tableView.getByText('CCCCCCCC…CCCCCC')).toHaveAttribute('title', 'C'.repeat(64)); + expect(tableView.getByText('Required message is missing')).toBeInTheDocument(); + }); + + it('renders and formats request mobile card fields', () => { + // Arrange: + const { mobileCard } = renderTable(); + + // Act: + const cardView = within(mobileCard); + + // Assert: + expect(cardView.getByText('#10')).toBeInTheDocument(); + expect(cardView.getByText('Completed')).toBeInTheDocument(); + expect(cardView.getByText('Sender')).toBeInTheDocument(); + expect(cardView.getByText('TCONKG47F…JMYM4I')).toHaveAttribute('title', REQUEST_ROW.senderAddress); + expect(cardView.getByText('Request')).toBeInTheDocument(); + expect(cardView.getByText('BBBBBBBB…BBBBBB')).toHaveAttribute('title', REQUEST_ROW.requestTransactionHash); + expect(cardView.getByText('300000')).toBeInTheDocument(); + expect(cardView.getByText('Payout Address')).toBeInTheDocument(); + expect(cardView.getByText('0x1f533cd…30ef8e')).toHaveAttribute('title', REQUEST_ROW.destinationAddress); + expect(cardView.getByText('AAAAAAAA…AAAAAA')).toHaveAttribute('title', REQUEST_ROW.payoutTransactionHash); + expect(cardView.getByText('1')).toHaveAttribute('title', '1000000 PPM'); + expect(cardView.getByText('357.429175')).toBeInTheDocument(); + expect(cardView.getByText('299642.570825')).toBeInTheDocument(); + }); + + it('renders and formats errors mobile card fields', () => { + // Arrange: + const { mobileCard } = renderTable({ + rows: [ERROR_ROW], + tab: ERROR_TAB + }); + + // Act: + const cardView = within(mobileCard); + + // Assert: + expect(cardView.getByText('#13')).toBeInTheDocument(); + expect(cardView.getByText('Error')).toBeInTheDocument(); + expect(cardView.getByText('Sender')).toBeInTheDocument(); + expect(cardView.getByText('TARDV42KT…IXVJQY')).toHaveAttribute('title', ERROR_ROW.senderAddress); + expect(cardView.getByText('Request')).toBeInTheDocument(); + expect(cardView.getByText('CCCCCCCC…CCCCCC')).toHaveAttribute('title', ERROR_ROW.requestTransactionHash); + expect(cardView.getByText('1970-01-01 00:00:05 UTC')).toBeInTheDocument(); + expect(cardView.getByText('Required message is missing')).toBeInTheDocument(); + }); + + it('renders a placeholder when an error message is unavailable', () => { + // Arrange: + const row = { + ...ERROR_ROW, + errorMessage: null + }; + + // Act: + renderTable({ + rows: [row], + tab: ERROR_TAB + }); + + // Assert: + expect(screen.getAllByText('—')).toHaveLength(2); + }); +}); diff --git a/bridge/reporting/__tests__/components/ReportTableFields.test.jsx b/bridge/reporting/__tests__/components/ReportTableFields.test.jsx new file mode 100644 index 0000000000..527eab0eab --- /dev/null +++ b/bridge/reporting/__tests__/components/ReportTableFields.test.jsx @@ -0,0 +1,143 @@ +import { + AmountValue, + ExternalValue, + RateValue, + StatusBadge, + TransactionValue +} from '@/components/ReportTableFields'; +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; + +const SYMBOL_NETWORK = { + blockchain: 'symbol', + explorerUrl: 'https://symbol.example' +}; + +describe('StatusBadge', () => { + it('renders the configured payout status', () => { + // Act: + render(); + + // Assert: + expect(screen.getByText('Completed')).toBeInTheDocument(); + }); + + it('renders error details through an accessible tooltip', () => { + // Act: + render(); + const failedStatus = screen.getByLabelText('Failed: Payout rejected'); + + // Assert: + expect(failedStatus).toHaveTextContent('Failed'); + expect(failedStatus).toHaveAttribute('data-tooltip', 'Payout rejected'); + expect(failedStatus).toHaveAttribute('tabindex', '0'); + }); + + it('renders an unknown label for an unconfigured payout status', () => { + // Act: + render(); + + // Assert: + expect(screen.getByText('Unknown')).toBeInTheDocument(); + }); +}); + +describe('ExternalValue', () => { + it('renders a truncated value linked to the configured explorer', () => { + // Arrange: + const value = 'TCONKG47FW2ZEZBPV6G7F422LXBDSMVT3JMYM4I'; + + // Act: + render(); + const link = screen.getByRole('link'); + + // Assert: + expect(link).toHaveTextContent('TCONKG47F…JMYM4I'); + expect(link).toHaveAttribute('href', `https://symbol.example/accounts/${value}`); + expect(link).toHaveAttribute('title', value); + expect(link).toHaveAttribute('rel', 'noreferrer'); + expect(link).toHaveAttribute('target', '_blank'); + }); + + it('renders a value without a link when an explorer URL is unavailable', () => { + // Arrange: + const value = 'TCONKG47FW2ZEZBPV6G7F422LXBDSMVT3JMYM4I'; + + // Act: + render(); + const externalValue = screen.getByTitle(value); + + // Assert: + expect(externalValue).toHaveTextContent('TCONKG47F…JMYM4I'); + expect(externalValue).not.toHaveAttribute('href'); + }); + + it('renders a placeholder and empty title when the value is unavailable', () => { + // Act: + render(); + const placeholder = screen.getByText('—'); + + // Assert: + expect(placeholder).toHaveAttribute('title', ''); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); +}); + +describe('TransactionValue', () => { + it('renders the formatted timestamp and linked transaction hash', () => { + // Arrange: + const hash = 'A'.repeat(64); + + // Act: + render(); + const link = screen.getByRole('link'); + + // Assert: + expect(screen.getByText('1970-01-01 00:00:02 UTC')).toBeInTheDocument(); + expect(link).toHaveTextContent('AAAAAAAA…AAAAAA'); + expect(link).toHaveAttribute('href', `https://symbol.example/transactions/${hash}`); + expect(link).toHaveAttribute('title', hash); + }); +}); + +describe('AmountValue', () => { + const asset = { ticker: 'XYM', divisibility: 6 }; + + it('renders a formatted amount with its asset ticker and raw value', () => { + // Act: + render(); + const amount = screen.getByTitle('300000000000'); + + // Assert: + expect(amount).toHaveTextContent('300000'); + expect(amount).toHaveTextContent('XYM'); + }); + + it.each([null, undefined])('renders a placeholder and empty title when the value is %s', value => { + // Act: + render(); + const placeholder = screen.getByText('—'); + + // Assert: + expect(placeholder.parentElement).toHaveAttribute('title', ''); + expect(screen.queryByText('XYM')).not.toBeInTheDocument(); + }); +}); + +describe('RateValue', () => { + it('renders a formatted rate with its raw PPM value', () => { + // Act: + render(); + + // Assert: + expect(screen.getByText('1')).toHaveAttribute('title', '1000000 PPM'); + }); + + it.each([null, undefined])('renders a placeholder and empty title when the value is %s', value => { + // Act: + render(); + + // Assert: + expect(screen.getByText('—')).toHaveAttribute('title', ''); + }); +}); diff --git a/bridge/reporting/__tests__/components/SortHeader.test.jsx b/bridge/reporting/__tests__/components/SortHeader.test.jsx new file mode 100644 index 0000000000..844fcfcbc7 --- /dev/null +++ b/bridge/reporting/__tests__/components/SortHeader.test.jsx @@ -0,0 +1,26 @@ +import SortHeader from '@/components/SortHeader'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; + +describe('SortHeader', () => { + it.each([ + [0, 'ascending', '↓'], + [1, 'descending', '↑'] + ])('renders sort value %s as %s and handles a sort change', (sort, direction, indicator) => { + // Arrange: + const onSortChange = jest.fn(); + render(); + const button = screen.getByRole('button', { + name: `Sort by request block height ${direction}` + }); + + // Act: + fireEvent.click(button); + + // Assert: + expect(button).toHaveTextContent('Request hash'); + expect(button).toHaveTextContent(indicator); + expect(button).toHaveAttribute('title', 'Sorted by request block height'); + expect(onSortChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/bridge/reporting/__tests__/utils/format.test.js b/bridge/reporting/__tests__/utils/format.test.js new file mode 100644 index 0000000000..34b1d69032 --- /dev/null +++ b/bridge/reporting/__tests__/utils/format.test.js @@ -0,0 +1,86 @@ +import { createExplorerUrl, formatAtomicAmount, formatPpm, formatTimestamp } from '@/utils/format'; + +describe('report formatting', () => { + it('formats atomic values without losing integer precision', () => { + // Arrange: + const inputs = [ + ['100000000', 6], + ['151458904500184', 18], + ['999999999999999999999999', 18] + ]; + + // Act: + const formattedAmounts = inputs.map(([value, divisibility]) => formatAtomicAmount(value, divisibility)); + + // Assert: + expect(formattedAmounts).toEqual(['100', '0.000151458904500184', '999999.999999999999999999']); + }); + + it('formats conversion rate PPM values', () => { + // Arrange: + const rates = ['1000000', '999999', '2000000']; + + // Act: + const formattedRates = rates.map(formatPpm); + + // Assert: + expect(formattedRates).toEqual(['1', '0.999999', '2']); + }); + + it('formats Unix seconds as UTC', () => { + // Arrange: + const timestamps = [1759781792.879, null, 'hello']; + + // Act: + const formattedTimestamps = timestamps.map(formatTimestamp); + + // Assert: + expect(formattedTimestamps).toEqual(['2025-10-06 20:16:32 UTC', '—', '—']); + }); + + it('creates Symbol explorer URLs', () => { + // Arrange: + const network = { blockchain: 'symbol', explorerUrl: 'https://symbol.example/' }; + + // Act: + const transactionUrl = createExplorerUrl(network, 'transaction', 'ABC123'); + const accountUrl = createExplorerUrl(network, 'address', 'TADDRESS'); + + // Assert: + expect(transactionUrl).toBe('https://symbol.example/transactions/ABC123'); + expect(accountUrl).toBe('https://symbol.example/accounts/TADDRESS'); + }); + + it('creates Ethereum explorer URLs and normalizes transaction hash prefixes', () => { + // Arrange: + const network = { blockchain: 'ethereum', explorerUrl: 'https://ethereum.example/' }; + + // Act: + const transactionUrls = [ + createExplorerUrl(network, 'transaction', 'ABC123'), + createExplorerUrl(network, 'transaction', '0xABC123') + ]; + const addressUrl = createExplorerUrl(network, 'address', '0x123ABC'); + + // Assert: + expect(transactionUrls).toEqual([ + 'https://ethereum.example/tx/0xABC123', + 'https://ethereum.example/tx/0xABC123' + ]); + expect(addressUrl).toBe('https://ethereum.example/address/0x123ABC'); + }); + + it('returns null when explorer URL or value is missing', () => { + // Arrange: + const network = { blockchain: 'symbol', explorerUrl: 'https://symbol.example' }; + + // Act: + const urls = [ + createExplorerUrl({}, 'transaction', 'ABC123'), + createExplorerUrl(network, 'transaction', null) + ]; + + // Assert: + expect(urls).toEqual([null, null]); + }); +}); diff --git a/bridge/reporting/components/ReportTable.jsx b/bridge/reporting/components/ReportTable.jsx new file mode 100644 index 0000000000..982c4fd2df --- /dev/null +++ b/bridge/reporting/components/ReportTable.jsx @@ -0,0 +1,187 @@ +import { AmountValue, ExternalValue, RateValue, StatusBadge, TransactionValue } from '@/components/ReportTableFields'; +import SortHeader from '@/components/SortHeader'; +import styles from '@/styles/ReportTable.module.css'; + +const RequestRow = ({ row, tab, configuration }) => { + const sourceNetwork = configuration?.[tab.sourceNetwork]; + const destinationNetwork = configuration?.[tab.destinationNetwork]; + + return ( + + + + + + + + + + + + ); +}; + +const ErrorRow = ({ row, tab, configuration }) => { + const sourceNetwork = configuration?.[tab.sourceNetwork]; + return ( + + + + {row.errorMessage || '—'} + + ); +}; + +const MobileField = ({ label, children, wide }) => ( +
+
{label}
+
{children}
+
+); + +const RequestCard = ({ row, tab, configuration }) => { + const sourceNetwork = configuration?.[tab.sourceNetwork]; + const destinationNetwork = configuration?.[tab.destinationNetwork]; + return ( +
+
+ #{row.requestTransactionHeight} + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +}; + +const ErrorCard = ({ row, tab, configuration }) => { + const sourceNetwork = configuration?.[tab.sourceNetwork]; + return ( +
+
+ #{row.requestTransactionHeight} + Error +
+
+ + + + + + + {row.errorMessage || '—'} +
+
+ ); +}; + +const ReportTable = ({ rows, tab, configuration, sort, onSortChange }) => { + const isRequests = 'requests' === tab.resource; + return ( + <> + + + {isRequests ? ( + + + + + + + + + + + + ) : ( + + + + + + )} + + + {rows.map(row => isRequests + ? ( + + ) + : ( + + ))} + +
Payout statusSender address + + Request amountPayout addressPayout hashConversion ratePayout feePayout amount
Sender address + + Error message
+
+ {rows.map(row => isRequests + ? ( + + ) + : ( + + ))} +
+ + ); +}; + +export default ReportTable; diff --git a/bridge/reporting/components/ReportTableFields.jsx b/bridge/reporting/components/ReportTableFields.jsx new file mode 100644 index 0000000000..f573569cd4 --- /dev/null +++ b/bridge/reporting/components/ReportTableFields.jsx @@ -0,0 +1,57 @@ +import { PAYOUT_STATUS_DETAILS } from '@/constants'; +import styles from '@/styles/ReportTable.module.css'; +import { createExplorerUrl, formatAtomicAmount, formatPpm, formatTimestamp, truncateMiddle } from '@/utils/format'; + +export const StatusBadge = ({ status, errorMessage }) => { + const details = PAYOUT_STATUS_DETAILS[status] || { label: 'Unknown', tone: 'neutral' }; + const className = `${styles.statusBadge} ${styles[`status_${details.tone}`]}`; + + if (!errorMessage) + return {details.label}; + + return ( + + {details.label} + + ); +}; + +export const ExternalValue = ({ network, type, value, truncateType = 'default' }) => { + const url = createExplorerUrl(network, type, value); + const label = 'hash' === truncateType ? truncateMiddle(value, 8, 6) : truncateMiddle(value, 9, 6); + + if (!url) + return {label}; + + return ( + + {label} + + + ); +}; + +export const TransactionValue = ({ hash, timestamp, network }) => ( +
+ {formatTimestamp(timestamp)} + +
+); + +export const AmountValue = ({ value, asset }) => ( +
+ {formatAtomicAmount(value, asset.divisibility)} + {null !== value && value !== undefined && {asset.ticker}} +
+); + +export const RateValue = ({ value }) => ( + + {formatPpm(value)} + +); diff --git a/bridge/reporting/components/SortHeader.jsx b/bridge/reporting/components/SortHeader.jsx new file mode 100644 index 0000000000..7af9ff73af --- /dev/null +++ b/bridge/reporting/components/SortHeader.jsx @@ -0,0 +1,16 @@ +import styles from '@/styles/ReportTable.module.css'; + +const SortHeader = ({ sort, onSortChange }) => ( + +); + +export default SortHeader; diff --git a/bridge/reporting/constants/index.js b/bridge/reporting/constants/index.js index 8ab5cc70ab..ee1ad278ca 100644 --- a/bridge/reporting/constants/index.js +++ b/bridge/reporting/constants/index.js @@ -1 +1,15 @@ export const PAGE_SIZE = 100; + +export const PAYOUT_STATUS = { + UNPROCESSED: 0, + SENT: 1, + COMPLETED: 2, + FAILED: 3 +}; + +export const PAYOUT_STATUS_DETAILS = { + [PAYOUT_STATUS.UNPROCESSED]: { label: 'Unprocessed', tone: 'neutral' }, + [PAYOUT_STATUS.SENT]: { label: 'Sent', tone: 'info' }, + [PAYOUT_STATUS.COMPLETED]: { label: 'Completed', tone: 'success' }, + [PAYOUT_STATUS.FAILED]: { label: 'Failed', tone: 'danger' } +}; diff --git a/bridge/reporting/jest.config.js b/bridge/reporting/jest.config.js index 6040ae4e1f..a685f6cf42 100644 --- a/bridge/reporting/jest.config.js +++ b/bridge/reporting/jest.config.js @@ -7,6 +7,7 @@ module.exports = createJestConfig({ moduleNameMapper: { '^@/(.*)$': '/$1' }, + coverageProvider: 'babel', modulePathIgnorePatterns: ['/.next/'], setupFilesAfterEnv: ['/setupTests.js'], testEnvironment: 'jsdom' diff --git a/bridge/reporting/package-lock.json b/bridge/reporting/package-lock.json index ebfab7387b..7ebb1b811c 100644 --- a/bridge/reporting/package-lock.json +++ b/bridge/reporting/package-lock.json @@ -17,7 +17,6 @@ "@testing-library/jest-dom": "^6.4.6", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", - "c8": "^12.0.0", "eslint": "^8.57.1", "eslint-config-next": "15.5.6", "eslint-import-resolver-jsconfig": "^1.1.0", @@ -3409,250 +3408,6 @@ "dev": true, "license": "MIT" }, - "node_modules/c8": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz", - "integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.1", - "@istanbuljs/schema": "^0.1.3", - "find-up": "^5.0.0", - "foreground-child": "^3.1.1", - "istanbul-lib-coverage": "^3.2.0", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.1.6", - "test-exclude": "^8.0.0", - "v8-to-istanbul": "^9.0.0", - "yargs": "^18.0.0", - "yargs-parser": "^21.1.1" - }, - "bin": { - "c8": "bin/c8.js" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - }, - "peerDependencies": { - "monocart-coverage-reports": "^2" - }, - "peerDependenciesMeta": { - "monocart-coverage-reports": { - "optional": true - } - } - }, - "node_modules/c8/node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/c8/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/c8/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/c8/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/c8/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/c8/node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/c8/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/c8/node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/c8/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/c8/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/c8/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/c8/node_modules/test-exclude": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", - "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^13.0.6", - "minimatch": "^10.2.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/c8/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/c8/node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/c8/node_modules/yargs/node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -5221,36 +4976,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -5362,19 +5087,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -7753,16 +7465,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8248,33 +7950,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/bridge/reporting/package.json b/bridge/reporting/package.json index edfcf48d34..0464cd2530 100644 --- a/bridge/reporting/package.json +++ b/bridge/reporting/package.json @@ -9,7 +9,7 @@ "test": "jest", "lint": "eslint --ext js,jsx .", "lint:fix": "eslint --ext js,jsx . --fix", - "test:jenkins": "c8 --reporter=lcov --reporter=text --report-dir=coverage npm run test", + "test:jenkins": "jest --coverage --coverageProvider=v8", "version": "echo $npm_package_version" }, "dependencies": { @@ -22,7 +22,6 @@ "@testing-library/jest-dom": "^6.4.6", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", - "c8": "^12.0.0", "eslint": "^8.57.1", "eslint-config-next": "15.5.6", "eslint-import-resolver-jsconfig": "^1.1.0", diff --git a/bridge/reporting/styles/ReportTable.module.css b/bridge/reporting/styles/ReportTable.module.css new file mode 100644 index 0000000000..2bd18a8776 --- /dev/null +++ b/bridge/reporting/styles/ReportTable.module.css @@ -0,0 +1,248 @@ +.table { + width: 100%; + color: var(--color-text); + font: 700 11px/1.45 var(--font-data); + border-collapse: separate; + border-spacing: 0; + table-layout: fixed; +} + +.requestTable { + min-width: 1480px; +} + +.errorTable { + min-width: 880px; +} + +.table thead { + position: sticky; + top: 0; + z-index: 5; +} + +.table th { + height: 40px; + padding: 0 12px; + color: var(--color-muted); + font: 700 9px/1 var(--font-data); + letter-spacing: 0.09em; + text-align: left; + text-transform: uppercase; + background: #1b1527; + border-bottom: 1px solid var(--color-line); +} + +.requestTable th:nth-child(1) { width: 118px; } +.requestTable th:nth-child(2) { width: 170px; } +.requestTable th:nth-child(3) { width: 205px; } +.requestTable th:nth-child(4) { width: 130px; } +.requestTable th:nth-child(5) { width: 170px; } +.requestTable th:nth-child(6) { width: 205px; } +.requestTable th:nth-child(7) { width: 115px; } +.requestTable th:nth-child(8) { width: 130px; } +.requestTable th:nth-child(9) { width: 140px; } +.errorTable th:nth-child(1) { width: 220px; } +.errorTable th:nth-child(2) { width: 250px; } + +.table td { + height: 52px; + padding: 8px 12px; + vertical-align: middle; + background: rgba(34, 28, 49, 0.62); + border-bottom: 1px solid #342d42; +} + +.table tbody tr:nth-child(even) td { + background: rgba(25, 19, 36, 0.88); +} + +.table tbody tr:hover td { + background: #30283e; +} + +.sortButton { + display: flex; + gap: 8px; + align-items: center; + padding: 0; + color: inherit; + font: inherit; + letter-spacing: inherit; + text-transform: inherit; + background: transparent; + border: 0; + cursor: pointer; +} + +.sortButton span { + color: var(--color-cyan); + font-size: 13px; +} + +.statusBadge { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 82px; + height: 24px; + padding: 0 10px; + font: 700 9px/1 var(--font-data); + letter-spacing: 0.04em; + text-transform: uppercase; + border: 1px solid currentColor; + border-radius: 999px; +} + +.status_neutral { color: #a6a0ac; background: rgba(166, 160, 172, 0.1); } +.status_info { color: var(--color-cyan); background: rgba(38, 195, 242, 0.1); } +.status_success { color: var(--color-success); background: rgba(139, 224, 160, 0.1); } +.status_danger { color: var(--color-danger); background: rgba(255, 146, 157, 0.1); } + +.statusWithTooltip { + cursor: help; +} + +.statusWithTooltip::after { + position: absolute; + bottom: calc(100% + 9px); + left: 0; + z-index: 20; + display: none; + width: max-content; + max-width: 280px; + padding: 9px 11px; + color: var(--color-text); + font: 400 11px/1.4 var(--font-body); + letter-spacing: 0; + text-align: left; + text-transform: none; + background: #06000d; + border: 1px solid var(--color-danger); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45); + content: attr(data-tooltip); +} + +.statusWithTooltip:hover::after, +.statusWithTooltip:focus::after { + display: block; +} + +.externalValue { + display: inline-flex; + gap: 5px; + align-items: center; + max-width: 100%; + overflow: hidden; + color: #bcecff; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; +} + +.externalValue:hover { + color: var(--color-cyan); + text-decoration: underline; +} + +.externalMark { + color: var(--color-purple); + font-size: 9px; +} + +.transactionValue, +.amountValue { + display: flex; + flex-direction: column; + gap: 3px; +} + +.timestamp, +.amountValue small { + color: var(--color-muted); + font: 400 9px/1.2 var(--font-body); +} + +.amountValue span, +.rateValue { + font-variant-numeric: tabular-nums; +} + +.amountValue small { + color: var(--color-purple); + font-family: var(--font-data); +} + +.errorMessage { + color: #ffd8dc; + font-family: var(--font-body); + font-weight: 400; +} + +.mobileList { + display: none; +} + +@media (max-width: 700px) { + .table { + display: none; + } + + .mobileList { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px; + } + + .mobileCard { + padding: 12px; + background: var(--color-surface); + border: 1px solid var(--color-line); + border-left: 2px solid var(--color-purple); + } + + .mobileCardTop { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + padding-bottom: 9px; + border-bottom: 1px solid var(--color-line); + } + + .mobileRowId { + color: var(--color-muted); + font: 700 9px/1 var(--font-data); + } + + .mobileGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px 12px; + margin: 0; + } + + .mobileField { + min-width: 0; + } + + .mobileFieldWide { + grid-column: 1 / -1; + } + + .mobileField dt { + margin-bottom: 4px; + color: var(--color-muted); + font: 700 8px/1 var(--font-data); + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .mobileField dd { + min-width: 0; + margin: 0; + font: 700 11px/1.4 var(--font-data); + } +} diff --git a/bridge/reporting/utils/format.js b/bridge/reporting/utils/format.js new file mode 100644 index 0000000000..8a20c15d1a --- /dev/null +++ b/bridge/reporting/utils/format.js @@ -0,0 +1,53 @@ +export const formatAtomicAmount = (value, divisibility) => { + if (null === value || value === undefined || '' === value) + return '—'; + + const digits = String(value); + + if (!/^\d+$/.test(digits)) + return digits; + + if (!divisibility) + return digits; + + const padded = digits.padStart(divisibility + 1, '0'); + const whole = padded.slice(0, -divisibility); + const fraction = padded.slice(-divisibility).replace(/0+$/, ''); + + return `${whole}${fraction ? `.${fraction}` : ''}`; +}; + +export const formatPpm = value => formatAtomicAmount(value, 6); + +export const formatTimestamp = value => { + if (null === value || value === undefined) + return '—'; + + const date = new Date(Number(value) * 1000); + if (Number.isNaN(date.getTime())) + return '—'; + + return `${date.toISOString().slice(0, 19).replace('T', ' ')} UTC`; +}; + +export const truncateMiddle = (value, start = 8, end = 6) => { + if (!value) + return '—'; + + const text = String(value); + return text.length <= start + end ? text : `${text.slice(0, start)}…${text.slice(-end)}`; +}; + +export const createExplorerUrl = (network, type, value) => { + if (!network?.explorerUrl || !value) + return null; + + const baseUrl = network.explorerUrl.replace(/\/$/, ''); + if ('ethereum' === network.blockchain) { + const path = 'transaction' === type ? 'tx' : 'address'; + const normalizedValue = 'transaction' === type ? `0x${value.replace(/^0x/i, '')}` : value; + return `${baseUrl}/${path}/${normalizedValue}`; + } + + return `${baseUrl}/${'transaction' === type ? 'transactions' : 'accounts'}/${value}`; +};