From 01f293da6712d9ed0b186a4cb0f509a834108f79 Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Tue, 21 Jul 2026 17:23:08 +0800 Subject: [PATCH 01/23] [bridge/reporting] feat: added script and install c8 for report --- bridge/reporting/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridge/reporting/package.json b/bridge/reporting/package.json index c98da6e770..adf07cd154 100644 --- a/bridge/reporting/package.json +++ b/bridge/reporting/package.json @@ -8,7 +8,7 @@ "start": "next start", "test": "jest", "lint": "eslint --ext js,jsx .", - "test:jenkins": "c8 --reporter=lcov --reporter=text --report-dir=coverage npm run test -- --passWithNoTests", + "test:jenkins": "c8 --reporter=lcov --reporter=text --report-dir=coverage npm run test", "version": "echo $npm_package_version" }, "dependencies": { From 0219b15365f7ef6c6cc1624837a8cd3f51314d30 Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Tue, 21 Jul 2026 17:29:45 +0800 Subject: [PATCH 02/23] [monorepo] feat: update bridge reporting info into readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ea231b9d0f..bb7ef7da4f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It includes our optin manager. | component | lint | build | test | coverage | package | |-----------|------|-------|------|----------| ------- | | [@bridge](bridge) | [![lint][bridge-lint]][bridge-job] | | [![test][bridge-test]][bridge-job] | [![][bridge-cov]][bridge-cov-link] -| [@bridge/reporting](bridge/reporting/) | [![lint][bridge-reporting-lint]][bridge-reporting-job] | | [![test][bridge-reporting-test]][bridge-reporting-job] | [![][bridge-reporting-cov]][bridge-reporting-cov-link] +| [@bridge/reporting](bridge/reporting) | [![lint][bridge-reporting-lint]][bridge-reporting-job] | | [![test][bridge-reporting-test]][bridge-reporting-job] | [![][bridge-reporting-cov]][bridge-reporting-cov-link] | [@explorer/frontend](explorer/frontend) | [![lint][explorer-frontend-lint]][explorer-frontend-job] | | [![test][explorer-frontend-test]][explorer-frontend-job] | [![][explorer-frontend-cov]][explorer-frontend-cov-link] | [@explorer/nodewatch](explorer/nodewatch) | [![lint][explorer-nodewatch-lint]][explorer-nodewatch-job] | | [![test][explorer-nodewatch-test]][explorer-nodewatch-job] | [![][explorer-nodewatch-cov]][explorer-nodewatch-cov-link] | [@explorer/puller](explorer/puller) | [![lint][explorer-puller-lint]][explorer-puller-job] | | [![test][explorer-puller-test]][explorer-puller-job] | [![][explorer-puller-cov]][explorer-puller-cov-link] From 5e7caa06cff3b5233f2453a5c482a8a1352917f2 Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Tue, 21 Jul 2026 22:02:41 +0800 Subject: [PATCH 03/23] [bridge/reporting] fix: added temporary flag --- bridge/reporting/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridge/reporting/package.json b/bridge/reporting/package.json index adf07cd154..c98da6e770 100644 --- a/bridge/reporting/package.json +++ b/bridge/reporting/package.json @@ -8,7 +8,7 @@ "start": "next start", "test": "jest", "lint": "eslint --ext js,jsx .", - "test:jenkins": "c8 --reporter=lcov --reporter=text --report-dir=coverage npm run test", + "test:jenkins": "c8 --reporter=lcov --reporter=text --report-dir=coverage npm run test -- --passWithNoTests", "version": "echo $npm_package_version" }, "dependencies": { From 1519818fe166055a9ddbdb5747a754c07324a729 Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Wed, 22 Jul 2026 01:59:25 +0800 Subject: [PATCH 04/23] [bridge/reporting] feat: added makeGetRequest method in utils server --- .../reporting/__tests__/utils/server.test.js | 39 +++++++++++++++++++ bridge/reporting/utils/server.js | 22 +++++++++++ 2 files changed, 61 insertions(+) create mode 100644 bridge/reporting/__tests__/utils/server.test.js create mode 100644 bridge/reporting/utils/server.js diff --git a/bridge/reporting/__tests__/utils/server.test.js b/bridge/reporting/__tests__/utils/server.test.js new file mode 100644 index 0000000000..90103ced4f --- /dev/null +++ b/bridge/reporting/__tests__/utils/server.test.js @@ -0,0 +1,39 @@ +import { makeGetRequest } from '@/utils/server'; +import axios from 'axios'; + +jest.mock('axios'); + +describe('server requests', () => { + it('forwards an abort signal to axios', async () => { + // Arrange: + const { signal } = new AbortController(); + axios.get.mockResolvedValue({ data: { enabled: true } }); + + // Act: + const response = await makeGetRequest('https://bridge.example', { signal, timeout: 5000 }); + + // Assert: + expect(response).toEqual({ enabled: true }); + expect(axios.get).toHaveBeenCalledWith('https://bridge.example', { + signal, + timeout: 5000 + }); + }); + + it('cancellation error when the signal is aborted', async () => { + // Arrange: + const abortController = new AbortController(); + const cancellationError = Object.assign(new Error('canceled'), { name: 'CanceledError' }); + axios.get.mockImplementation((url, { signal }) => new Promise((resolve, reject) => { + signal.addEventListener('abort', () => reject(cancellationError)); + })); + + // Act: + const request = makeGetRequest('https://bridge.example', { signal: abortController.signal }); + abortController.abort(); + + // Assert: + expect(abortController.signal.aborted).toBe(true); + await expect(request).rejects.toBe(cancellationError); + }); +}); diff --git a/bridge/reporting/utils/server.js b/bridge/reporting/utils/server.js new file mode 100644 index 0000000000..06a237d629 --- /dev/null +++ b/bridge/reporting/utils/server.js @@ -0,0 +1,22 @@ +import config from '@/config'; +import axios from 'axios'; + +/** + * Options for a GET request. + * @typedef {Object} GetRequestOptions + * @property {number} [timeout=config.PUBLIC_REQUEST_TIMEOUT] Request timeout in milliseconds. + * @property {AbortSignal} [signal] Signal used to cancel the request. + */ + +/** + * Sends a GET request and returns the response body. + * @param {string} url Request URL. + * @param {GetRequestOptions} [options={}] GET request options. + * @returns {Promise<*>} Response body returned by the endpoint. + */ +export const makeGetRequest = async (url, options = {}) => { + const { timeout = config.PUBLIC_REQUEST_TIMEOUT, signal } = options; + const response = await axios.get(url, { signal, timeout }); + + return response.data; +}; From bf8559a0d380b21a1aef52f09b80489d85cecb6e Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Wed, 22 Jul 2026 02:27:37 +0800 Subject: [PATCH 05/23] [bridge/reporting] feat: Added validation for parse search input --- .../__tests__/utils/validation.test.js | 50 +++++++++++++++++++ bridge/reporting/utils/validation.js | 34 +++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 bridge/reporting/__tests__/utils/validation.test.js create mode 100644 bridge/reporting/utils/validation.js diff --git a/bridge/reporting/__tests__/utils/validation.test.js b/bridge/reporting/__tests__/utils/validation.test.js new file mode 100644 index 0000000000..7da1a72eb8 --- /dev/null +++ b/bridge/reporting/__tests__/utils/validation.test.js @@ -0,0 +1,50 @@ +import { parseSearchInput } from '@/utils/validation'; + +describe('parseSearchInput', () => { + it('parse a Symbol address', () => { + // Arrange: + const address = 'TARDV42KTAIZEF64EQT4NXT7K55DHWBEFIXVJQY'; + + // Act: + const parsedSearch = parseSearchInput(address.toLowerCase()); + + // Assert: + expect(parsedSearch).toEqual({ type: 'address', value: address }); + }); + + it('parse an Ethereum address', () => { + // Arrange: + const address = '0x0f02eE65e510eA30006e63aAcC668428aD7A998E'; + + // Act: + const parsedSearch = parseSearchInput(address); + + // Assert: + expect(parsedSearch).toEqual({ type: 'address', value: address }); + }); + + it('strips the Ethereum prefix from a transaction hash', () => { + // Arrange: + const hash = 'a'.repeat(64); + + // Act: + const parsedSearch = parseSearchInput(`0x${hash}`); + + // Assert: + expect(parsedSearch).toEqual({ type: 'hash', value: hash.toUpperCase() }); + }); + + it('accepts an empty filter and rejects invalid text', () => { + // Arrange: + const emptySearch = ''; + const invalidSearch = 'not an address'; + + // Act: + const emptyResult = parseSearchInput(emptySearch); + const invalidResult = parseSearchInput(invalidSearch); + + // Assert: + expect(emptyResult).toEqual({ type: null, value: '' }); + expect(invalidResult).toBeNull(); + }); +}); diff --git a/bridge/reporting/utils/validation.js b/bridge/reporting/utils/validation.js new file mode 100644 index 0000000000..2f0d7ce34d --- /dev/null +++ b/bridge/reporting/utils/validation.js @@ -0,0 +1,34 @@ +const TRANSACTION_HASH_PATTERN = /^(?:0x)?[0-9a-fA-F]{64}$/; +const ETHEREUM_ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/; +const SYMBOL_ADDRESS_PATTERN = /^[A-Z2-7]{39}$/; + +/** + * A normalized search value and its detected type. + * @typedef {Object} ParsedSearchInput + * @property {'address'|'hash'|null} type Detected search type, or `null` for an empty input. + * @property {string} value Normalized search value. + */ + +/** + * Parses and normalizes a Symbol address, Ethereum address, or transaction hash. + * @param {string} [input=''] Search input. + * @returns {ParsedSearchInput|null} Parsed input, or `null` when the input is invalid. + */ +export const parseSearchInput = input => { + const value = (input || '').trim(); + + if (!value) + return { type: null, value: '' }; + + if (TRANSACTION_HASH_PATTERN.test(value)) + return { type: 'hash', value: value.replace(/^0x/i, '').toUpperCase() }; + + if (ETHEREUM_ADDRESS_PATTERN.test(value)) + return { type: 'address', value }; + + const normalizedAddress = value.replace(/-/g, '').toUpperCase(); + if (SYMBOL_ADDRESS_PATTERN.test(normalizedAddress)) + return { type: 'address', value: normalizedAddress }; + + return null; +}; From 0059008086ee7698ea19a1c7482363d0ef2757d8 Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Wed, 22 Jul 2026 02:54:51 +0800 Subject: [PATCH 06/23] [bridge/reporting] feat: Added api --- bridge/reporting/__tests__/api/bridge.test.js | 147 ++++++++++++++++++ bridge/reporting/api/bridge.js | 112 +++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 bridge/reporting/__tests__/api/bridge.test.js create mode 100644 bridge/reporting/api/bridge.js diff --git a/bridge/reporting/__tests__/api/bridge.test.js b/bridge/reporting/__tests__/api/bridge.test.js new file mode 100644 index 0000000000..d41a3984d3 --- /dev/null +++ b/bridge/reporting/__tests__/api/bridge.test.js @@ -0,0 +1,147 @@ +import { buildReportUrl, fetchAllReportRows, fetchBridgeConfiguration, fetchReportPage } from '@/api/bridge'; +import config from '@/config'; +import { createReportTabs } from '@/constants'; +import { makeGetRequest } from '@/utils/server'; + +jest.mock('@/utils/server'); + +describe('bridge API', () => { + const baseCriteria = { + baseUrl: 'https://bridge.example/wrapped/', + operation: 'wrap', + resource: 'requests', + offset: 0, + limit: 100, + sort: 0 + }; + + describe('build report url', () => { + it('builds an unfiltered request URL with payout status', () => { + // Arrange: + const criteria = { ...baseCriteria, payoutStatus: 2 }; + + // Act: + const url = buildReportUrl(criteria); + + // Assert: + expect(url).toBe('https://bridge.example/wrapped/wrap/requests?offset=0&limit=100&sort=0&payout_status=2'); + }); + + it('builds an address URL that can match sender or destination', () => { + // Arrange: + const address = 'TARDV42KTAIZEF64EQT4NXT7K55DHWBEFIXVJQY'; + + // Act: + const url = buildReportUrl({ ...baseCriteria, search: address }); + + // Assert: + expect(url).toContain(`/wrap/requests/${address}?`); + }); + + it('builds a hash URL', () => { + // Arrange: + const hash = 'a'.repeat(64); + + // Act: + const url = buildReportUrl({ ...baseCriteria, search: `0x${hash}` }); + + // Assert: + expect(url).toContain(`/wrap/requests/hash/${hash.toUpperCase()}?`); + }); + + it('does not send payout status to an errors endpoint', () => { + // Arrange: + const criteria = { ...baseCriteria, resource: 'errors', payoutStatus: 3 }; + + // Act: + const url = buildReportUrl(criteria); + + // Assert: + expect(url).not.toContain('payout_status'); + }); + + it('rejects unwrap url from the native bridge', () => { + // Arrange: + const criteria = { + ...baseCriteria, + baseUrl: `${config.PUBLIC_BRIDGE_NATIVE_URL}/`, + operation: 'unwrap' + }; + + // Act: + const buildUrl = () => buildReportUrl(criteria); + + // Assert: + expect(buildUrl).toThrow('The native bridge does not support unwrap operations'); + }); + + it('rejects an invalid address or transaction hash', () => { + // Arrange: + const criteria = { ...baseCriteria, search: 'invalid search' }; + + // Act: + const buildUrl = () => buildReportUrl(criteria); + + // Assert: + expect(buildUrl).toThrow('Invalid address or transaction hash'); + }); + }); + + it('fetches bridge configuration from the base URL', async () => { + // Arrange: + makeGetRequest.mockResolvedValue({ enabled: true }); + + // Act: + const configuration = await fetchBridgeConfiguration('https://bridge.example/wrapped/'); + + // Assert: + expect(configuration).toEqual({ enabled: true }); + expect(makeGetRequest).toHaveBeenCalledWith('https://bridge.example/wrapped'); + }); + + it('returns pages with data', async () => { + // Arrange: + makeGetRequest.mockResolvedValue([{ id: 1 }, { id: 2 }]); + + // Act: + const page = await fetchReportPage({ ...baseCriteria, limit: 2, offset: 0 }); + + // Assert: + expect(page).toEqual({ + data: [{ id: 1 }, { id: 2 }], + hasMore: true, + nextOffset: 2 + }); + }); + + it('returns last page', async () => { + // Arrange: + makeGetRequest.mockResolvedValue([{ id: 5 }]); + + // Act: + const page = await fetchReportPage({ ...baseCriteria, limit: 2, offset: 4 }); + + // Assert: + expect(page).toEqual({ + data: [{ id: 5 }], + hasMore: false, + nextOffset: 5 + }); + }); + + it('fetches all CSV rows until a short page is returned', async () => { + // Arrange: + makeGetRequest + .mockResolvedValueOnce([{ id: 1 }, { id: 2 }]) + .mockResolvedValueOnce([{ id: 3 }]); + const progress = jest.fn(); + + // Act: + const rows = await fetchAllReportRows({ ...baseCriteria, limit: 2, offset: 0 }, progress); + + // Assert: + expect(rows).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); + expect(progress).toHaveBeenNthCalledWith(1, 2); + expect(progress).toHaveBeenNthCalledWith(2, 3); + }); +}); diff --git a/bridge/reporting/api/bridge.js b/bridge/reporting/api/bridge.js new file mode 100644 index 0000000000..ea88b5c566 --- /dev/null +++ b/bridge/reporting/api/bridge.js @@ -0,0 +1,112 @@ +import config from '@/config'; +import { PAGE_SIZE } from '@/constants'; +import { makeGetRequest } from '@/utils/server'; +import { parseSearchInput } from '@/utils/validation'; + +/** + * Criteria used to query a bridge report endpoint. + * @typedef {Object} ReportCriteria + * @property {string} baseUrl Bridge API base URL. + * @property {'wrap'|'unwrap'} operation Bridge operation. + * @property {'requests'|'errors'} resource Report resource. + * @property {number} [offset=0] Zero-based row offset. + * @property {number} [limit=PAGE_SIZE] Maximum number of rows to request. + * @property {string} [search] Symbol/Ethereum address or transaction hash used to filter rows. + * @property {0|1|2|3|null} [payoutStatus] Payout status filter for request reports. + * @property {number} [sort=0] Sort direction accepted by the bridge API. + * @property {AbortSignal} [signal] Signal used to cancel the HTTP request. + */ + +/** + * A page returned by a bridge report endpoint. + * @typedef {Object} ReportPage + * @property {Object[]} data Report rows returned by the endpoint. + * @property {boolean} hasMore Whether another page might be available. + * @property {number} nextOffset Offset to use for the next request. + */ + + +/** + * Removes a trailing slash from a bridge API base URL. + * @param {string} baseUrl Bridge API base URL. + * @returns {string} Base URL without a trailing slash. + */ +const normalizeBaseUrl = baseUrl => (baseUrl || '').replace(/\/$/, ''); + +/** + * Builds the URL for a bridge report request. + * @param {ReportCriteria} criteria Report filters and pagination criteria. + * @returns {string} Bridge report URL with encoded path values and query parameters. + * @throws {Error} If the search value is not a supported address or transaction hash. + * @throws {Error} If an unwrap report is requested from the native bridge. + */ +export const buildReportUrl = ({ baseUrl, operation, resource, offset = 0, limit = PAGE_SIZE, search, payoutStatus, sort = 0 }) => { + const normalizedBaseUrl = normalizeBaseUrl(baseUrl); + if ('unwrap' === operation && normalizeBaseUrl(config.PUBLIC_BRIDGE_NATIVE_URL) === normalizedBaseUrl) + throw new Error('The native bridge does not support unwrap operations'); + + const parsedSearch = parseSearchInput(search); + if (search && !parsedSearch) + throw new Error('Invalid address or transaction hash'); + + let path = `${normalizedBaseUrl}/${operation}/${resource}`; + if ('address' === parsedSearch?.type) + path += `/${encodeURIComponent(parsedSearch.value)}`; + if ('hash' === parsedSearch?.type) + path += `/hash/${encodeURIComponent(parsedSearch.value)}`; + + const parameters = new URLSearchParams({ offset: String(offset), limit: String(limit), sort: String(sort) }); + if ('requests' === resource && null !== payoutStatus && undefined !== payoutStatus) + parameters.set('payout_status', String(payoutStatus)); + + return `${path}?${parameters.toString()}`; +}; + +/** + * Fetches configuration metadata from a bridge API. + * @param {string} baseUrl Bridge API base URL. + * @returns {Promise} Bridge configuration response body. + */ +export const fetchBridgeConfiguration = async baseUrl => { + return makeGetRequest(normalizeBaseUrl(baseUrl)); +}; + +/** + * Fetches one page of bridge report rows and derives its pagination metadata. + * @param {ReportCriteria} criteria Report filters, pagination criteria, and optional cancellation signal. + * @returns {Promise} Report rows and pagination metadata. + */ +export const fetchReportPage = async criteria => { + const { limit = PAGE_SIZE, offset = 0, signal } = criteria; + const url = buildReportUrl(criteria); + const data = await makeGetRequest(url, { signal }); + + return { + data, + hasMore: data.length === limit, + nextOffset: offset + data.length + }; +}; + +/** + * Fetches every page of a bridge report, starting at offset zero. + * @param {ReportCriteria} criteria Report filters and page size. + * @param {function(number): void} [onProgress] Called after each page with the total number of rows fetched. + * @returns {Promise} All report rows in API order. + */ +export const fetchAllReportRows = async (criteria, onProgress) => { + const limit = criteria.limit || PAGE_SIZE; + let offset = 0; + let rows = []; + let hasMore = true; + + while (hasMore) { + const { data, hasMore: pageHasMore, nextOffset } = await fetchReportPage({ ...criteria, limit, offset }); + rows = [...rows, ...data]; + offset = nextOffset; + hasMore = pageHasMore; + onProgress?.(rows.length); + } + + return rows; +}; From c945a2d8be7bc64e138dbbd4f8490979fa085915 Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Wed, 22 Jul 2026 02:56:51 +0800 Subject: [PATCH 07/23] [bridge/reporting] feat: added constants file --- bridge/reporting/constants/index.js | 1 + 1 file changed, 1 insertion(+) create mode 100644 bridge/reporting/constants/index.js diff --git a/bridge/reporting/constants/index.js b/bridge/reporting/constants/index.js new file mode 100644 index 0000000000..8ab5cc70ab --- /dev/null +++ b/bridge/reporting/constants/index.js @@ -0,0 +1 @@ +export const PAGE_SIZE = 100; From af7e9c9e8e429fec107d2e7d802c2c6091f8e576 Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Wed, 22 Jul 2026 02:58:01 +0800 Subject: [PATCH 08/23] [bridge/reporting] fix: removed flag --- bridge/reporting/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridge/reporting/package.json b/bridge/reporting/package.json index c98da6e770..adf07cd154 100644 --- a/bridge/reporting/package.json +++ b/bridge/reporting/package.json @@ -8,7 +8,7 @@ "start": "next start", "test": "jest", "lint": "eslint --ext js,jsx .", - "test:jenkins": "c8 --reporter=lcov --reporter=text --report-dir=coverage npm run test -- --passWithNoTests", + "test:jenkins": "c8 --reporter=lcov --reporter=text --report-dir=coverage npm run test", "version": "echo $npm_package_version" }, "dependencies": { From f1fd2f2b3779000fa4e0dcf01a420cce431b682f Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Wed, 22 Jul 2026 02:59:44 +0800 Subject: [PATCH 09/23] [bridge/reporting] fix: removed unused import --- bridge/reporting/__tests__/api/bridge.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/bridge/reporting/__tests__/api/bridge.test.js b/bridge/reporting/__tests__/api/bridge.test.js index d41a3984d3..8068b0a7de 100644 --- a/bridge/reporting/__tests__/api/bridge.test.js +++ b/bridge/reporting/__tests__/api/bridge.test.js @@ -1,6 +1,5 @@ import { buildReportUrl, fetchAllReportRows, fetchBridgeConfiguration, fetchReportPage } from '@/api/bridge'; import config from '@/config'; -import { createReportTabs } from '@/constants'; import { makeGetRequest } from '@/utils/server'; jest.mock('@/utils/server'); From 5cfe6386a9896098a0263c21f0fc23916a75612f Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Wed, 22 Jul 2026 03:34:18 +0800 Subject: [PATCH 10/23] [bridge/reporting] fix: rename --- bridge/reporting/__tests__/api/bridge.test.js | 18 +++++++++--------- bridge/reporting/api/bridge.js | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/bridge/reporting/__tests__/api/bridge.test.js b/bridge/reporting/__tests__/api/bridge.test.js index 8068b0a7de..93aec27903 100644 --- a/bridge/reporting/__tests__/api/bridge.test.js +++ b/bridge/reporting/__tests__/api/bridge.test.js @@ -1,4 +1,4 @@ -import { buildReportUrl, fetchAllReportRows, fetchBridgeConfiguration, fetchReportPage } from '@/api/bridge'; +import { buildBridgeUrl, fetchAllReportRows, fetchBridgeConfiguration, fetchReportPage } from '@/api/bridge'; import config from '@/config'; import { makeGetRequest } from '@/utils/server'; @@ -20,7 +20,7 @@ describe('bridge API', () => { const criteria = { ...baseCriteria, payoutStatus: 2 }; // Act: - const url = buildReportUrl(criteria); + const url = buildBridgeUrl(criteria); // Assert: expect(url).toBe('https://bridge.example/wrapped/wrap/requests?offset=0&limit=100&sort=0&payout_status=2'); @@ -31,7 +31,7 @@ describe('bridge API', () => { const address = 'TARDV42KTAIZEF64EQT4NXT7K55DHWBEFIXVJQY'; // Act: - const url = buildReportUrl({ ...baseCriteria, search: address }); + const url = buildBridgeUrl({ ...baseCriteria, search: address }); // Assert: expect(url).toContain(`/wrap/requests/${address}?`); @@ -42,7 +42,7 @@ describe('bridge API', () => { const hash = 'a'.repeat(64); // Act: - const url = buildReportUrl({ ...baseCriteria, search: `0x${hash}` }); + const url = buildBridgeUrl({ ...baseCriteria, search: `0x${hash}` }); // Assert: expect(url).toContain(`/wrap/requests/hash/${hash.toUpperCase()}?`); @@ -53,7 +53,7 @@ describe('bridge API', () => { const criteria = { ...baseCriteria, resource: 'errors', payoutStatus: 3 }; // Act: - const url = buildReportUrl(criteria); + const url = buildBridgeUrl(criteria); // Assert: expect(url).not.toContain('payout_status'); @@ -68,10 +68,10 @@ describe('bridge API', () => { }; // Act: - const buildUrl = () => buildReportUrl(criteria); + const url = () => buildBridgeUrl(criteria); // Assert: - expect(buildUrl).toThrow('The native bridge does not support unwrap operations'); + expect(url).toThrow('The native bridge does not support unwrap operations'); }); it('rejects an invalid address or transaction hash', () => { @@ -79,10 +79,10 @@ describe('bridge API', () => { const criteria = { ...baseCriteria, search: 'invalid search' }; // Act: - const buildUrl = () => buildReportUrl(criteria); + const url = () => buildBridgeUrl(criteria); // Assert: - expect(buildUrl).toThrow('Invalid address or transaction hash'); + expect(url).toThrow('Invalid address or transaction hash'); }); }); diff --git a/bridge/reporting/api/bridge.js b/bridge/reporting/api/bridge.js index ea88b5c566..661a2f5561 100644 --- a/bridge/reporting/api/bridge.js +++ b/bridge/reporting/api/bridge.js @@ -40,7 +40,7 @@ const normalizeBaseUrl = baseUrl => (baseUrl || '').replace(/\/$/, ''); * @throws {Error} If the search value is not a supported address or transaction hash. * @throws {Error} If an unwrap report is requested from the native bridge. */ -export const buildReportUrl = ({ baseUrl, operation, resource, offset = 0, limit = PAGE_SIZE, search, payoutStatus, sort = 0 }) => { +export const buildBridgeUrl = ({ baseUrl, operation, resource, offset = 0, limit = PAGE_SIZE, search, payoutStatus, sort = 0 }) => { const normalizedBaseUrl = normalizeBaseUrl(baseUrl); if ('unwrap' === operation && normalizeBaseUrl(config.PUBLIC_BRIDGE_NATIVE_URL) === normalizedBaseUrl) throw new Error('The native bridge does not support unwrap operations'); @@ -78,7 +78,7 @@ export const fetchBridgeConfiguration = async baseUrl => { */ export const fetchReportPage = async criteria => { const { limit = PAGE_SIZE, offset = 0, signal } = criteria; - const url = buildReportUrl(criteria); + const url = buildBridgeUrl(criteria); const data = await makeGetRequest(url, { signal }); return { From ca5e5a4ce4739ca850d98c90651ef5813cf1f828 Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Wed, 22 Jul 2026 16:53:01 +0800 Subject: [PATCH 11/23] [bridge/reporting] feat: added env in jest config --- bridge/reporting/jest.config.js | 1 + bridge/reporting/setupTests.js | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 bridge/reporting/setupTests.js diff --git a/bridge/reporting/jest.config.js b/bridge/reporting/jest.config.js index 8a9f2b30ce..6040ae4e1f 100644 --- a/bridge/reporting/jest.config.js +++ b/bridge/reporting/jest.config.js @@ -8,5 +8,6 @@ module.exports = createJestConfig({ '^@/(.*)$': '/$1' }, modulePathIgnorePatterns: ['/.next/'], + setupFilesAfterEnv: ['/setupTests.js'], testEnvironment: 'jsdom' }); diff --git a/bridge/reporting/setupTests.js b/bridge/reporting/setupTests.js new file mode 100644 index 0000000000..87b4ef34a3 --- /dev/null +++ b/bridge/reporting/setupTests.js @@ -0,0 +1,8 @@ +const appConfig = { + PUBLIC_BRIDGE_WRAPPED_URL: 'https://bridge.example/wrapped', + PUBLIC_BRIDGE_NATIVE_URL: 'https://bridge.example/native', + PUBLIC_REQUEST_TIMEOUT: 5000 +}; + +window.appConfig = appConfig; +Object.assign(process.env, appConfig); \ No newline at end of file From e159ce2ddd16f02411a567addfb118e6a5f61455 Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Thu, 23 Jul 2026 01:07:36 +0800 Subject: [PATCH 12/23] [bridge/reporting] fix: use request for both configuration --- bridge/reporting/__tests__/api/bridge.test.js | 12 +++++++++-- bridge/reporting/api/bridge.js | 21 ++++++++++++++----- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/bridge/reporting/__tests__/api/bridge.test.js b/bridge/reporting/__tests__/api/bridge.test.js index 93aec27903..196342e543 100644 --- a/bridge/reporting/__tests__/api/bridge.test.js +++ b/bridge/reporting/__tests__/api/bridge.test.js @@ -91,11 +91,19 @@ describe('bridge API', () => { makeGetRequest.mockResolvedValue({ enabled: true }); // Act: - const configuration = await fetchBridgeConfiguration('https://bridge.example/wrapped/'); + const configuration = await fetchBridgeConfiguration(); // Assert: - expect(configuration).toEqual({ enabled: true }); + expect(configuration).toEqual({ + wrapped: { + enabled: true + }, + native: { + enabled: true + } + }); expect(makeGetRequest).toHaveBeenCalledWith('https://bridge.example/wrapped'); + expect(makeGetRequest).toHaveBeenCalledWith('https://bridge.example/native'); }); it('returns pages with data', async () => { diff --git a/bridge/reporting/api/bridge.js b/bridge/reporting/api/bridge.js index 661a2f5561..684d72478e 100644 --- a/bridge/reporting/api/bridge.js +++ b/bridge/reporting/api/bridge.js @@ -63,12 +63,23 @@ export const buildBridgeUrl = ({ baseUrl, operation, resource, offset = 0, limit }; /** - * Fetches configuration metadata from a bridge API. - * @param {string} baseUrl Bridge API base URL. - * @returns {Promise} Bridge configuration response body. + * Fetches configuration metadata for the wrapped and native bridges concurrently. + * A bridge configuration is null when its request fails. + * @returns {Promise<{wrapped: Object|null, native: Object|null}>} Configuration metadata for each bridge. */ -export const fetchBridgeConfiguration = async baseUrl => { - return makeGetRequest(normalizeBaseUrl(baseUrl)); +export const fetchBridgeConfiguration = async () => { + const wrappedUrl = config.PUBLIC_BRIDGE_WRAPPED_URL; + const nativeUrl = config.PUBLIC_BRIDGE_NATIVE_URL; + + const [wrappedResult, nativeResult] = await Promise.allSettled([ + makeGetRequest(normalizeBaseUrl(wrappedUrl)), + makeGetRequest(normalizeBaseUrl(nativeUrl)) + ]) + + return { + wrapped: wrappedResult.status === 'fulfilled' ? wrappedResult.value : null, + native: nativeResult.status === 'fulfilled' ? nativeResult.value : null + } }; /** From 6f4d50552cb466465d338906c57f9f5691023898 Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Thu, 23 Jul 2026 15:18:57 +0800 Subject: [PATCH 13/23] [bridge/reporting] fix: lint issue --- bridge/reporting/api/bridge.js | 8 ++++---- bridge/reporting/package.json | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/bridge/reporting/api/bridge.js b/bridge/reporting/api/bridge.js index 684d72478e..c227da3552 100644 --- a/bridge/reporting/api/bridge.js +++ b/bridge/reporting/api/bridge.js @@ -74,12 +74,12 @@ export const fetchBridgeConfiguration = async () => { const [wrappedResult, nativeResult] = await Promise.allSettled([ makeGetRequest(normalizeBaseUrl(wrappedUrl)), makeGetRequest(normalizeBaseUrl(nativeUrl)) - ]) + ]); return { - wrapped: wrappedResult.status === 'fulfilled' ? wrappedResult.value : null, - native: nativeResult.status === 'fulfilled' ? nativeResult.value : null - } + wrapped: 'fulfilled' === wrappedResult.status ? wrappedResult.value : null, + native: 'fulfilled' === nativeResult.status ? nativeResult.value : null + }; }; /** diff --git a/bridge/reporting/package.json b/bridge/reporting/package.json index adf07cd154..edfcf48d34 100644 --- a/bridge/reporting/package.json +++ b/bridge/reporting/package.json @@ -8,6 +8,7 @@ "start": "next start", "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", "version": "echo $npm_package_version" }, From d686a2d0fceb1b6a5300c46e07afc6e9e5490262 Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Thu, 23 Jul 2026 15:12:33 +0800 Subject: [PATCH 14/23] [bridge/reporting] feat: added value formatting --- .../reporting/__tests__/utils/format.test.js | 86 +++++++++++++++++++ bridge/reporting/utils/format.js | 53 ++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 bridge/reporting/__tests__/utils/format.test.js create mode 100644 bridge/reporting/utils/format.js 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/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}`; +}; From f84f9b47ca4edb9cf4e7a5ca1af4385c19bf9a5e Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Fri, 24 Jul 2026 02:52:13 +0800 Subject: [PATCH 15/23] [bridge/reporting] feat: added report table component --- .../__tests__/components/ReportTable.test.jsx | 225 ++++++++++++++++ bridge/reporting/components/ReportTable.jsx | 254 ++++++++++++++++++ bridge/reporting/constants/index.js | 14 + .../reporting/styles/ReportTable.module.css | 248 +++++++++++++++++ 4 files changed, 741 insertions(+) create mode 100644 bridge/reporting/__tests__/components/ReportTable.test.jsx create mode 100644 bridge/reporting/components/ReportTable.jsx create mode 100644 bridge/reporting/styles/ReportTable.module.css diff --git a/bridge/reporting/__tests__/components/ReportTable.test.jsx b/bridge/reporting/__tests__/components/ReportTable.test.jsx new file mode 100644 index 0000000000..97899a4b6c --- /dev/null +++ b/bridge/reporting/__tests__/components/ReportTable.test.jsx @@ -0,0 +1,225 @@ +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') + }; +}; + +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 failed status details through an accessible tooltip', () => { + // Arrange: + const { table } = renderTable({ + rows: [{ + ...REQUEST_ROW, + errorMessage: 'Payout rejected', + payoutStatus: 3 + }] + }); + + // Act: + const failedStatus = within(table).getByLabelText('Failed: Payout rejected'); + + // Assert: + expect(failedStatus).toHaveTextContent('Failed'); + expect(failedStatus).toHaveAttribute('data-tooltip', 'Payout rejected'); + }); + + it('renders links addresses and transactions to their configured explorers', () => { + // Arrange: + const { table } = renderTable(); + const tableView = within(table); + const symbolTransactionUrl = `https://symbol.example/transactions/${REQUEST_ROW.requestTransactionHash}`; + const ethereumTransactionUrl = `https://ethereum.example/tx/0x${REQUEST_ROW.payoutTransactionHash}`; + + // Act: + const senderLink = tableView.getByTitle(REQUEST_ROW.senderAddress); + const requestLink = tableView.getByTitle(REQUEST_ROW.requestTransactionHash); + const destinationLink = tableView.getByTitle(REQUEST_ROW.destinationAddress); + const payoutLink = tableView.getByTitle(REQUEST_ROW.payoutTransactionHash); + + // Assert: + expect(senderLink).toHaveAttribute( + 'href', + `https://symbol.example/accounts/${REQUEST_ROW.senderAddress}` + ); + expect(requestLink).toHaveAttribute('href', symbolTransactionUrl); + expect(destinationLink).toHaveAttribute( + 'href', + `https://ethereum.example/address/${REQUEST_ROW.destinationAddress}` + ); + expect(payoutLink).toHaveAttribute('href', ethereumTransactionUrl); + }); + + 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'); + }); + + 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: + renderTable(); + + // Act: + const mobileCard = screen.getByRole('article'); + 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: + renderTable({ + rows: [ERROR_ROW], + tab: ERROR_TAB + }); + + // Act: + const mobileCard = screen.getByRole('article'); + 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(); + }); +}); diff --git a/bridge/reporting/components/ReportTable.jsx b/bridge/reporting/components/ReportTable.jsx new file mode 100644 index 0000000000..9ad41b2c43 --- /dev/null +++ b/bridge/reporting/components/ReportTable.jsx @@ -0,0 +1,254 @@ +import { PAYOUT_STATUS_DETAILS } from '@/constants'; +import styles from '@/styles/ReportTable.module.css'; +import { createExplorerUrl, formatAtomicAmount, formatPpm, formatTimestamp, truncateMiddle } from '@/utils/format'; + +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} + + ); +}; + +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} + + + ); +}; + +const TransactionValue = ({ hash, timestamp, network }) => ( +
+ {formatTimestamp(timestamp)} + +
+); + +const AmountValue = ({ value, asset }) => ( +
+ {formatAtomicAmount(value, asset.divisibility)} + {null !== value && value !== undefined && {asset.ticker}} +
+); + +const RateValue = ({ value }) => ( + + {formatPpm(value)} + +); + +const SortHeader = ({ sort, onSortChange }) => ( + +); + +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/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/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); + } +} From c0dbf01f672a518bfcc9c3d4433bc19b7f85fb5a Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Sat, 25 Jul 2026 02:53:41 +0800 Subject: [PATCH 16/23] [bridge/reporting] fix: add more test --- .../__tests__/components/ReportTable.test.jsx | 58 +++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/bridge/reporting/__tests__/components/ReportTable.test.jsx b/bridge/reporting/__tests__/components/ReportTable.test.jsx index 97899a4b6c..2744626efa 100644 --- a/bridge/reporting/__tests__/components/ReportTable.test.jsx +++ b/bridge/reporting/__tests__/components/ReportTable.test.jsx @@ -75,7 +75,8 @@ const renderTable = ({ return { ...result, onSortChange, - table: screen.getByRole('table') + table: screen.getByRole('table'), + mobileCard: screen.getByRole('article') }; }; @@ -179,10 +180,9 @@ describe('ReportTable', () => { it('renders and formats request mobile card fields', () => { // Arrange: - renderTable(); + const { mobileCard } = renderTable(); // Act: - const mobileCard = screen.getByRole('article'); const cardView = within(mobileCard); // Assert: @@ -203,13 +203,12 @@ describe('ReportTable', () => { it('renders and formats errors mobile card fields', () => { // Arrange: - renderTable({ + const { mobileCard } = renderTable({ rows: [ERROR_ROW], tab: ERROR_TAB }); // Act: - const mobileCard = screen.getByRole('article'); const cardView = within(mobileCard); // Assert: @@ -222,4 +221,53 @@ describe('ReportTable', () => { expect(cardView.getByText('1970-01-01 00:00:05 UTC')).toBeInTheDocument(); expect(cardView.getByText('Required message is missing')).toBeInTheDocument(); }); + + it('renders unknown payout status', () => { + // Arrange: + const row = { + ...REQUEST_ROW, + payoutStatus: 99 + }; + + // Act: + renderTable({ rows: [row] }); + + // Assert: + expect(screen.getAllByText('Unknown')).toHaveLength(2); + }); + + it('renders values without links when explorer URLs are unavailable', () => { + // Arrange: + const configuration = { + nativeNetwork: { blockchain: 'symbol' }, + wrappedNetwork: { blockchain: 'ethereum' } + }; + const { table } = renderTable({ configuration }); + + // Act: + const tableView = within(table); + const senderValue = tableView.getByTitle(REQUEST_ROW.senderAddress); + const requestValue = tableView.getByTitle(REQUEST_ROW.requestTransactionHash); + + // Assert: + expect(senderValue).not.toHaveAttribute('href'); + expect(requestValue).not.toHaveAttribute('href'); + }); + + 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); + }); }); From 5436ff3e12ab0f453752f059ad67bbaf8a66d825 Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Sat, 25 Jul 2026 04:22:54 +0800 Subject: [PATCH 17/23] [bridge/reporting] fix: add babel setting in coverage --- bridge/reporting/jest.config.js | 1 + 1 file changed, 1 insertion(+) 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' From 7e2ca0f74924eb16f9bfffaa0a3187650061e780 Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Sat, 25 Jul 2026 15:59:28 +0800 Subject: [PATCH 18/23] [bridge/reporting] fix: coverage invalid highlights --- bridge/reporting/package-lock.json | 325 ----------------------------- bridge/reporting/package.json | 3 +- 2 files changed, 1 insertion(+), 327 deletions(-) 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..33958f9b9a 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", "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", From 762269fc2e9c3c6d0f519687911b4fa7b38dc8ba Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Sun, 26 Jul 2026 05:10:19 +0800 Subject: [PATCH 19/23] [bridge/reporting] fix: add more test --- .../__tests__/components/ReportTable.test.jsx | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/bridge/reporting/__tests__/components/ReportTable.test.jsx b/bridge/reporting/__tests__/components/ReportTable.test.jsx index 2744626efa..1801ab38c7 100644 --- a/bridge/reporting/__tests__/components/ReportTable.test.jsx +++ b/bridge/reporting/__tests__/components/ReportTable.test.jsx @@ -162,6 +162,32 @@ describe('ReportTable', () => { 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({ @@ -254,6 +280,25 @@ describe('ReportTable', () => { expect(requestValue).not.toHaveAttribute('href'); }); + it('renders empty title when value is unavailable', () => { + // Arrange: + const row = { + ...REQUEST_ROW, + payoutConversionRate: null, + requestAmount: null, + senderAddress: null + }; + const { table } = renderTable({ rows: [row] }); + + // Act: + const cells = within(table).getAllByRole('cell'); + + // Assert: + expect(cells[1].firstChild).toHaveAttribute('title', ''); + expect(cells[3].firstChild).toHaveAttribute('title', ''); + expect(cells[6].firstChild).toHaveAttribute('title', ''); + }); + it('renders a placeholder when an error message is unavailable', () => { // Arrange: const row = { From f006e6141893640d74fa31f6fce4ba546c40e4f0 Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Sun, 26 Jul 2026 17:28:00 +0800 Subject: [PATCH 20/23] [bridge/reporting] fix: Added sortHeader --- .../__tests__/components/SortHeader.test.jsx | 26 +++++++++++++++++++ bridge/reporting/components/SortHeader.jsx | 16 ++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 bridge/reporting/__tests__/components/SortHeader.test.jsx create mode 100644 bridge/reporting/components/SortHeader.jsx 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/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; From 4e092ac033aae5faec68f8785fdce0c330789938 Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Sun, 26 Jul 2026 17:30:58 +0800 Subject: [PATCH 21/23] [bridge/reporting] fix: refactor extract table field --- .../components/ReportTableFields.test.jsx | 143 ++++++++++++++++++ .../components/ReportTableFields.jsx | 57 +++++++ 2 files changed, 200 insertions(+) create mode 100644 bridge/reporting/__tests__/components/ReportTableFields.test.jsx create mode 100644 bridge/reporting/components/ReportTableFields.jsx 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/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)} + +); From ca5825d3806728d5a6f629446809f9857cbb34bb Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Sun, 26 Jul 2026 17:32:23 +0800 Subject: [PATCH 22/23] [bridge/reporting] fix: refactor report table component --- .../__tests__/components/ReportTable.test.jsx | 103 +----------------- bridge/reporting/components/ReportTable.jsx | 71 +----------- 2 files changed, 6 insertions(+), 168 deletions(-) diff --git a/bridge/reporting/__tests__/components/ReportTable.test.jsx b/bridge/reporting/__tests__/components/ReportTable.test.jsx index 1801ab38c7..766bcf6ca5 100644 --- a/bridge/reporting/__tests__/components/ReportTable.test.jsx +++ b/bridge/reporting/__tests__/components/ReportTable.test.jsx @@ -102,50 +102,6 @@ describe('ReportTable', () => { expect(tableView.getByText('1970-01-01 00:00:03 UTC')).toBeInTheDocument(); }); - it('renders failed status details through an accessible tooltip', () => { - // Arrange: - const { table } = renderTable({ - rows: [{ - ...REQUEST_ROW, - errorMessage: 'Payout rejected', - payoutStatus: 3 - }] - }); - - // Act: - const failedStatus = within(table).getByLabelText('Failed: Payout rejected'); - - // Assert: - expect(failedStatus).toHaveTextContent('Failed'); - expect(failedStatus).toHaveAttribute('data-tooltip', 'Payout rejected'); - }); - - it('renders links addresses and transactions to their configured explorers', () => { - // Arrange: - const { table } = renderTable(); - const tableView = within(table); - const symbolTransactionUrl = `https://symbol.example/transactions/${REQUEST_ROW.requestTransactionHash}`; - const ethereumTransactionUrl = `https://ethereum.example/tx/0x${REQUEST_ROW.payoutTransactionHash}`; - - // Act: - const senderLink = tableView.getByTitle(REQUEST_ROW.senderAddress); - const requestLink = tableView.getByTitle(REQUEST_ROW.requestTransactionHash); - const destinationLink = tableView.getByTitle(REQUEST_ROW.destinationAddress); - const payoutLink = tableView.getByTitle(REQUEST_ROW.payoutTransactionHash); - - // Assert: - expect(senderLink).toHaveAttribute( - 'href', - `https://symbol.example/accounts/${REQUEST_ROW.senderAddress}` - ); - expect(requestLink).toHaveAttribute('href', symbolTransactionUrl); - expect(destinationLink).toHaveAttribute( - 'href', - `https://ethereum.example/address/${REQUEST_ROW.destinationAddress}` - ); - expect(payoutLink).toHaveAttribute('href', ethereumTransactionUrl); - }); - it('renders the active sort direction and handles sort changes', () => { // Arrange: const onSortChange = jest.fn(); @@ -162,7 +118,7 @@ describe('ReportTable', () => { expect(sortButton.closest('th')).toHaveAttribute('aria-sort', 'descending'); }); - const runDescendingSortTest = (params) => { + const runDescendingSortTest = params => { // Arrange: const { table } = renderTable(params); @@ -174,10 +130,10 @@ describe('ReportTable', () => { // Assert: expect(sortButton).toHaveTextContent('↑'); expect(sortButton.closest('th')).toHaveAttribute('aria-sort', 'ascending'); - } + }; it('renders the descending sort direction for request reports', () => { - runDescendingSortTest({sort: 1}) + runDescendingSortTest({ sort: 1 }); }); it('renders the descending sort direction for error reports', () => { @@ -185,7 +141,7 @@ describe('ReportTable', () => { rows: [ERROR_ROW], sort: 1, tab: ERROR_TAB - }) + }); }); it('renders and formats errors report fields', () => { @@ -248,57 +204,6 @@ describe('ReportTable', () => { expect(cardView.getByText('Required message is missing')).toBeInTheDocument(); }); - it('renders unknown payout status', () => { - // Arrange: - const row = { - ...REQUEST_ROW, - payoutStatus: 99 - }; - - // Act: - renderTable({ rows: [row] }); - - // Assert: - expect(screen.getAllByText('Unknown')).toHaveLength(2); - }); - - it('renders values without links when explorer URLs are unavailable', () => { - // Arrange: - const configuration = { - nativeNetwork: { blockchain: 'symbol' }, - wrappedNetwork: { blockchain: 'ethereum' } - }; - const { table } = renderTable({ configuration }); - - // Act: - const tableView = within(table); - const senderValue = tableView.getByTitle(REQUEST_ROW.senderAddress); - const requestValue = tableView.getByTitle(REQUEST_ROW.requestTransactionHash); - - // Assert: - expect(senderValue).not.toHaveAttribute('href'); - expect(requestValue).not.toHaveAttribute('href'); - }); - - it('renders empty title when value is unavailable', () => { - // Arrange: - const row = { - ...REQUEST_ROW, - payoutConversionRate: null, - requestAmount: null, - senderAddress: null - }; - const { table } = renderTable({ rows: [row] }); - - // Act: - const cells = within(table).getAllByRole('cell'); - - // Assert: - expect(cells[1].firstChild).toHaveAttribute('title', ''); - expect(cells[3].firstChild).toHaveAttribute('title', ''); - expect(cells[6].firstChild).toHaveAttribute('title', ''); - }); - it('renders a placeholder when an error message is unavailable', () => { // Arrange: const row = { diff --git a/bridge/reporting/components/ReportTable.jsx b/bridge/reporting/components/ReportTable.jsx index 9ad41b2c43..982c4fd2df 100644 --- a/bridge/reporting/components/ReportTable.jsx +++ b/bridge/reporting/components/ReportTable.jsx @@ -1,73 +1,6 @@ -import { PAYOUT_STATUS_DETAILS } from '@/constants'; +import { AmountValue, ExternalValue, RateValue, StatusBadge, TransactionValue } from '@/components/ReportTableFields'; +import SortHeader from '@/components/SortHeader'; import styles from '@/styles/ReportTable.module.css'; -import { createExplorerUrl, formatAtomicAmount, formatPpm, formatTimestamp, truncateMiddle } from '@/utils/format'; - -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} - - ); -}; - -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} - - - ); -}; - -const TransactionValue = ({ hash, timestamp, network }) => ( -
- {formatTimestamp(timestamp)} - -
-); - -const AmountValue = ({ value, asset }) => ( -
- {formatAtomicAmount(value, asset.divisibility)} - {null !== value && value !== undefined && {asset.ticker}} -
-); - -const RateValue = ({ value }) => ( - - {formatPpm(value)} - -); - -const SortHeader = ({ sort, onSortChange }) => ( - -); const RequestRow = ({ row, tab, configuration }) => { const sourceNetwork = configuration?.[tab.sourceNetwork]; From 5c75270cdaf59bf08f697f5a0c2a00f3e0aa0f1c Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Mon, 27 Jul 2026 18:12:05 +0800 Subject: [PATCH 23/23] [bridge/reporting] fix: update coverage issue --- bridge/reporting/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridge/reporting/package.json b/bridge/reporting/package.json index 33958f9b9a..15c81f20db 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": "jest --coverage", + "test:jenkins": "jest --coverage --coverageReporters=lcov --coverageReporters=text", "version": "echo $npm_package_version" }, "dependencies": {