diff --git a/.gitea/workflows/validate-on-pr.yml b/.gitea/workflows/validate-on-pr.yml new file mode 100644 index 0000000..95c719c --- /dev/null +++ b/.gitea/workflows/validate-on-pr.yml @@ -0,0 +1,70 @@ +name: Validate Explorer + +on: + pull_request: + branches: [main, master] + paths: + - '.gitea/workflows/validate-on-pr.yml' + - 'frontend/**' + - 'scripts/e2e-*.spec.ts' + - 'package.json' + - 'package-lock.json' + - 'playwright.config.ts' + push: + branches: [main, master] + paths: + - '.gitea/workflows/validate-on-pr.yml' + - 'frontend/**' + - 'scripts/e2e-*.spec.ts' + - 'package.json' + - 'package-lock.json' + - 'playwright.config.ts' + +jobs: + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint, type-check, and unit tests + run: npm test + + smoke-e2e: + runs-on: ubuntu-latest + needs: frontend + if: github.event_name == 'push' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: package-lock.json + + - name: Install root dependencies + run: npm ci + + - name: Install Playwright browser + run: npx playwright install chromium + + - name: Run live sprint smoke tests + env: + EXPLORER_URL: https://explorer.d-bis.org + run: npm run e2e -- scripts/e2e-sprint-smoke.spec.ts diff --git a/docs/TOKEN_LIST_SURFACES.md b/docs/TOKEN_LIST_SURFACES.md index 4098c44..814ffe1 100644 --- a/docs/TOKEN_LIST_SURFACES.md +++ b/docs/TOKEN_LIST_SURFACES.md @@ -6,6 +6,14 @@ The explorer uses two public token-list endpoints. Application code should pick |---------|----------|----------| | `wallet` | `/api/v1/report/token-list?chainId=138` (fallback: config) | Wallet SSR, MetaMask watch list, featured-token dedup inputs | | `catalog` | report (fallback: config) | `/tokens`, search token inference, homepage price feed curation | -| `extended` | `/api/config/token-list` | Full Metamask dual-chain catalog, provenance lookup merge | +| `extended` | `/api/config/token-list` | Full Metamask dual-chain catalog, provenance lookup merge, operations/liquidity/system/pools inventory | Report list is the canonical Chain 138 trading set (31 tokens live). Config list is the extended catalog (190+ entries across chains). + +## Page mapping + +| Page / surface | Surface | Notes | +|----------------|---------|-------| +| `/wallet` | `wallet` | SSR + MetaMask watch list | +| `/tokens`, `/search`, homepage price feed | `catalog` | Canonical trading set with config fallback | +| `/liquidity`, `/operations`, `/system`, `/pools` | `extended` | Full catalog with `TokenListSurfaceNote` label | diff --git a/frontend/src/components/common/TokenListSurfaceNote.tsx b/frontend/src/components/common/TokenListSurfaceNote.tsx new file mode 100644 index 0000000..7a2f748 --- /dev/null +++ b/frontend/src/components/common/TokenListSurfaceNote.tsx @@ -0,0 +1,13 @@ +import { TOKEN_LIST_SURFACE_LABELS, type TokenListSurface } from '@/services/api/tokenListSurfaces' + +interface TokenListSurfaceNoteProps { + surface?: TokenListSurface + className?: string +} + +export default function TokenListSurfaceNote({ + surface = 'extended', + className = 'text-sm text-gray-600 dark:text-gray-400', +}: TokenListSurfaceNoteProps) { + return

{TOKEN_LIST_SURFACE_LABELS[surface]}

+} diff --git a/frontend/src/components/explorer/LiquidityOperationsPage.tsx b/frontend/src/components/explorer/LiquidityOperationsPage.tsx index f56ea22..b4dee5f 100644 --- a/frontend/src/components/explorer/LiquidityOperationsPage.tsx +++ b/frontend/src/components/explorer/LiquidityOperationsPage.tsx @@ -3,7 +3,8 @@ import { useEffect, useMemo, useState } from 'react' import Link from 'next/link' import { Card } from '@/libs/frontend-ui-primitives' -import { configApi, type TokenListResponse } from '@/services/api/config' +import { type TokenListResponse } from '@/services/api/config' +import { tokensApi } from '@/services/api/tokens' import { aggregateLiquidityPools, featuredLiquiditySymbols, @@ -22,6 +23,7 @@ import ActivityContextPanel from '@/components/common/ActivityContextPanel' import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' import MarketEvidenceNote from '@/components/common/MarketEvidenceNote' import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel' +import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote' import { resolveEffectiveFreshness } from '@/utils/explorerFreshness' import { formatCurrency, @@ -98,7 +100,7 @@ export default function LiquidityOperationsPage({ const load = async () => { const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult, statsResult, bridgeResult] = await Promise.allSettled([ - configApi.getTokenList(), + tokensApi.listForSurface('extended', 138).then(({ ok, data }) => ({ tokens: ok ? data : [] })), routesApi.getRouteMatrix(), plannerApi.getCapabilities(), plannerApi.getInternalExecutionPlan(), @@ -273,6 +275,7 @@ export default function LiquidityOperationsPage({ public route matrix, planner capabilities, and mission-control token pool inventory together so integrators can inspect what Chain 138 is actually serving right now.

+ {loadingError ? ( diff --git a/frontend/src/components/explorer/OperationsHubPage.tsx b/frontend/src/components/explorer/OperationsHubPage.tsx index 342b5f9..acdf256 100644 --- a/frontend/src/components/explorer/OperationsHubPage.tsx +++ b/frontend/src/components/explorer/OperationsHubPage.tsx @@ -3,6 +3,7 @@ import Link from 'next/link' import { Card } from '@/libs/frontend-ui-primitives' import { explorerFeaturePages } from '@/data/explorerOperations' import { configApi, type CapabilitiesResponse, type NetworksConfigResponse, type TokenListResponse } from '@/services/api/config' +import { tokensApi } from '@/services/api/tokens' import { getMissionControlRelays, missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl' import { routesApi, type RouteMatrixResponse } from '@/services/api/routes' import { useUiMode } from '@/components/common/UiModeContext' @@ -10,6 +11,7 @@ import { summarizeChainActivity } from '@/utils/activityContext' import ActivityContextPanel from '@/components/common/ActivityContextPanel' import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel' +import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote' import { resolveEffectiveFreshness } from '@/utils/explorerFreshness' import { statsApi, type ExplorerStats } from '@/services/api/stats' @@ -88,7 +90,7 @@ export default function OperationsHubPage({ missionControlApi.getBridgeStatus(), routesApi.getRouteMatrix(), configApi.getNetworks(), - configApi.getTokenList(), + tokensApi.listForSurface('extended', 138).then(({ ok, data }) => ({ tokens: ok ? data : [] })), configApi.getCapabilities(), statsApi.get(), ]) @@ -248,6 +250,7 @@ export default function OperationsHubPage({
Chains ยท {(tokenList?.tokens || []).length} tokens across {tokenChainCoverage} networks
+ diff --git a/frontend/src/components/explorer/PoolsOperationsPage.tsx b/frontend/src/components/explorer/PoolsOperationsPage.tsx index 27ddabc..34a4595 100644 --- a/frontend/src/components/explorer/PoolsOperationsPage.tsx +++ b/frontend/src/components/explorer/PoolsOperationsPage.tsx @@ -3,7 +3,9 @@ import { useEffect, useMemo, useState } from 'react' import Link from 'next/link' import { Card } from '@/libs/frontend-ui-primitives' -import { configApi, type TokenListResponse } from '@/services/api/config' +import { type TokenListResponse } from '@/services/api/config' +import { tokensApi } from '@/services/api/tokens' +import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote' import { aggregateLiquidityPools, getRouteBackedPoolAddresses, @@ -28,7 +30,7 @@ export default function PoolsOperationsPage() { const load = async () => { const [tokenListResult, routeMatrixResult] = await Promise.allSettled([ - configApi.getTokenList(), + tokensApi.listForSurface('extended', 138).then(({ ok, data }) => ({ tokens: ok ? data : [] })), routesApi.getRouteMatrix(), ]) @@ -100,6 +102,7 @@ export default function PoolsOperationsPage() { This page now summarizes the live pool inventory discovered through mission-control token pool endpoints and cross-checks it against the current route matrix.

+ {loadingError ? ( diff --git a/frontend/src/components/explorer/SystemOperationsPage.tsx b/frontend/src/components/explorer/SystemOperationsPage.tsx index bbf5471..708b5ba 100644 --- a/frontend/src/components/explorer/SystemOperationsPage.tsx +++ b/frontend/src/components/explorer/SystemOperationsPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react' import { Card } from '@/libs/frontend-ui-primitives' import { explorerFeaturePages } from '@/data/explorerOperations' import { configApi, type CapabilitiesResponse, type NetworksConfigResponse, type TokenListResponse } from '@/services/api/config' +import { tokensApi } from '@/services/api/tokens' import { getMissionControlRelays, missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl' import { routesApi, type RouteMatrixResponse } from '@/services/api/routes' import { statsApi, type ExplorerStats } from '@/services/api/stats' @@ -11,6 +12,7 @@ import OperationsPageShell, { formatNumber, relativeAge, } from './OperationsPageShell' +import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote' interface SystemOperationsPageProps { initialBridgeStatus?: MissionControlBridgeStatusResponse | null @@ -46,7 +48,7 @@ export default function SystemOperationsPage({ await Promise.allSettled([ missionControlApi.getBridgeStatus(), configApi.getNetworks(), - configApi.getTokenList(), + tokensApi.listForSurface('extended', 138).then(({ ok, data }) => ({ tokens: ok ? data : [] })), configApi.getCapabilities(), routesApi.getRouteMatrix(), statsApi.get(), @@ -125,6 +127,7 @@ export default function SystemOperationsPage({ description={`${formatNumber(capabilities?.tracing?.supportedMethods?.length)} tracing methods published.`} /> +
diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index 3089006..479986c 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -32,6 +32,7 @@ import { HOME_PRICE_FEED_REFRESH_MS, resolveHomePriceFeedAddresses, } from '@/utils/featuredTokens' +import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh' type HomeStats = ExplorerStats @@ -180,14 +181,11 @@ export default function Home({ }, [chainId]) useEffect(() => { - loadDashboard() - const timer = window.setInterval(() => { - void loadDashboard() - }, HOME_DASHBOARD_REFRESH_MS) - - return () => { - window.clearInterval(timer) - } + void loadDashboard() + return createVisibilityAwarePoller({ + intervalMs: HOME_DASHBOARD_REFRESH_MS, + task: loadDashboard, + }) }, [loadDashboard]) useEffect(() => { @@ -210,7 +208,6 @@ export default function Home({ useEffect(() => { let cancelled = false - let timer: ReturnType | null = null const refreshFeaturedPrices = async () => { try { @@ -221,20 +218,18 @@ export default function Home({ if (!cancelled && process.env.NODE_ENV !== 'production') { console.warn('Failed to load featured token prices:', error) } - } finally { - if (!cancelled) { - timer = setTimeout(() => { - void refreshFeaturedPrices() - }, HOME_PRICE_FEED_REFRESH_MS) - } } } void refreshFeaturedPrices() + const stop = createVisibilityAwarePoller({ + intervalMs: HOME_PRICE_FEED_REFRESH_MS, + task: refreshFeaturedPrices, + }) return () => { cancelled = true - if (timer) clearTimeout(timer) + stop() } }, [loadFeaturedPrices]) @@ -307,6 +302,31 @@ export default function Home({ } }, []) + useEffect(() => { + if (relayFeedState !== 'fallback') return + + let cancelled = false + + const refreshSnapshot = async () => { + try { + const status = await missionControlApi.getBridgeStatus() + if (!cancelled) { + setBridgeStatus(status) + setRelaySummary(summarizeMissionControlRelay(status)) + } + } catch (error) { + if (!cancelled && process.env.NODE_ENV !== 'production') { + console.warn('Failed to refresh mission control snapshot:', error) + } + } + } + + return createVisibilityAwarePoller({ + intervalMs: HOME_DASHBOARD_REFRESH_MS, + task: refreshSnapshot, + }) + }, [relayFeedState]) + const relayToneClasses = relaySummary?.tone === 'danger' ? 'border-red-200 bg-red-50 text-red-900 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-100' diff --git a/frontend/src/components/wallet/WalletPage.tsx b/frontend/src/components/wallet/WalletPage.tsx index 54ca02f..79889e5 100644 --- a/frontend/src/components/wallet/WalletPage.tsx +++ b/frontend/src/components/wallet/WalletPage.tsx @@ -18,6 +18,7 @@ import { type TransactionSummary, } from '@/services/api/addresses' import { WALLET_SNAPSHOT_REFRESH_MS } from '@/utils/featuredTokens' +import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh' import { formatRelativeAge, formatTokenAmount } from '@/utils/format' import { isWatchlistEntry, @@ -146,8 +147,6 @@ export default function WalletPage(props: WalletPageProps) { useEffect(() => { let cancelled = false - let timer: ReturnType | null = null - if (!walletSession?.address) { setAddressInfo(null) setRecentAddressTransactions([]) @@ -170,20 +169,18 @@ export default function WalletPage(props: WalletPageProps) { setTokenBalances([]) setTokenTransfers([]) } - } finally { - if (!cancelled) { - timer = setTimeout(() => { - void refreshSnapshot() - }, WALLET_SNAPSHOT_REFRESH_MS) - } } } void refreshSnapshot() + const stop = createVisibilityAwarePoller({ + intervalMs: WALLET_SNAPSHOT_REFRESH_MS, + task: refreshSnapshot, + }) return () => { cancelled = true - if (timer) clearTimeout(timer) + stop() } }, [loadWalletSnapshot, walletSession?.address]) diff --git a/frontend/src/pages/liquidity/index.tsx b/frontend/src/pages/liquidity/index.tsx index 2c41051..34e992e 100644 --- a/frontend/src/pages/liquidity/index.tsx +++ b/frontend/src/pages/liquidity/index.tsx @@ -7,6 +7,7 @@ import type { MissionControlBridgeStatusResponse } from '@/services/api/missionC import type { ExplorerStats } from '@/services/api/stats' import { fetchPublicJson } from '@/utils/publicExplorer' import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext' +import { loadTokenListResponseForSurface } from '@/services/api/tokenListSurfaces' interface TokenPoolRecord { symbol: string @@ -46,7 +47,7 @@ export default function LiquidityPage(props: LiquidityPageProps) { export const getServerSideProps: GetServerSideProps = async () => { const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, internalPlanResult, truthContext] = await Promise.all([ - fetchPublicJson('/api/config/token-list').catch(() => null), + loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null), fetchPublicJson('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null), fetchPublicJson('/token-aggregation/api/v2/providers/capabilities?chainId=138').catch( () => null, diff --git a/frontend/src/pages/operations/index.tsx b/frontend/src/pages/operations/index.tsx index 6ef20fc..8633554 100644 --- a/frontend/src/pages/operations/index.tsx +++ b/frontend/src/pages/operations/index.tsx @@ -6,6 +6,7 @@ import type { CapabilitiesResponse, NetworksConfigResponse, TokenListResponse } import type { ExplorerStats } from '@/services/api/stats' import { fetchPublicJson } from '@/utils/publicExplorer' import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext' +import { loadTokenListResponseForSurface } from '@/services/api/tokenListSurfaces' interface OperationsPageProps { initialBridgeStatus: MissionControlBridgeStatusResponse | null @@ -24,7 +25,7 @@ export const getStaticProps: GetStaticProps = async () => { const [routesResult, networksResult, tokenListResult, capabilitiesResult, truthContext] = await Promise.all([ fetchPublicJson('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null), fetchPublicJson('/api/config/networks').catch(() => null), - fetchPublicJson('/api/config/token-list').catch(() => null), + loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null), fetchPublicJson('/api/config/capabilities').catch(() => null), fetchExplorerTruthContext(), ]) diff --git a/frontend/src/pages/system/index.tsx b/frontend/src/pages/system/index.tsx index c891852..acb6fb4 100644 --- a/frontend/src/pages/system/index.tsx +++ b/frontend/src/pages/system/index.tsx @@ -1,6 +1,7 @@ import type { GetServerSideProps } from 'next' import SystemOperationsPage from '@/components/explorer/SystemOperationsPage' import { fetchPublicJson } from '@/utils/publicExplorer' +import { loadTokenListResponseForSurface } from '@/services/api/tokenListSurfaces' import type { CapabilitiesResponse, NetworksConfigResponse, TokenListResponse } from '@/services/api/config' import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl' import type { RouteMatrixResponse } from '@/services/api/routes' @@ -23,7 +24,7 @@ export const getServerSideProps: GetServerSideProps = async () const [bridgeStatus, networksConfig, tokenList, capabilities, routeMatrix, stats] = await Promise.all([ fetchPublicJson('/explorer-api/v1/track1/bridge/status').catch(() => null), fetchPublicJson('/api/config/networks').catch(() => null), - fetchPublicJson('/api/config/token-list').catch(() => null), + loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null), fetchPublicJson('/api/config/capabilities').catch(() => null), fetchPublicJson('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null), fetchPublicJson('/api/v2/stats').then((value) => normalizeExplorerStats(value as never)).catch(() => null), diff --git a/frontend/src/services/api/missionControl.ts b/frontend/src/services/api/missionControl.ts index 220040b..63a3df7 100644 --- a/frontend/src/services/api/missionControl.ts +++ b/frontend/src/services/api/missionControl.ts @@ -280,27 +280,50 @@ export const missionControlApi = { return () => {} } - const eventSource = new window.EventSource(getMissionControlStreamUrl()) + let closed = false + let eventSource: EventSource | null = null + let reconnectTimer: ReturnType | null = null + let retryMs = 5_000 + const maxRetryMs = 60_000 const handleMessage = (event: MessageEvent) => { try { const payload = JSON.parse(event.data) as MissionControlBridgeStatusResponse + retryMs = 5_000 onStatus(payload) } catch (error) { onError?.(error) } } - eventSource.addEventListener('mission-control', handleMessage) - eventSource.onmessage = handleMessage + const connect = () => { + if (closed) return - eventSource.onerror = () => { - onError?.(new Error('Mission-control live stream connection lost')) + eventSource?.close() + eventSource = new window.EventSource(getMissionControlStreamUrl()) + eventSource.addEventListener('mission-control', handleMessage) + eventSource.onmessage = handleMessage + eventSource.onerror = () => { + eventSource?.close() + eventSource = null + onError?.(new Error('Mission-control live stream connection lost')) + if (closed) return + if (reconnectTimer) clearTimeout(reconnectTimer) + reconnectTimer = setTimeout(() => { + reconnectTimer = null + connect() + }, retryMs) + retryMs = Math.min(retryMs * 2, maxRetryMs) + } } + connect() + return () => { - eventSource.removeEventListener('mission-control', handleMessage) - eventSource.close() + closed = true + if (reconnectTimer) clearTimeout(reconnectTimer) + eventSource?.removeEventListener('mission-control', handleMessage) + eventSource?.close() } }, } diff --git a/frontend/src/services/api/tokenListSurfaces.ts b/frontend/src/services/api/tokenListSurfaces.ts index 3ad44b2..ccd7db9 100644 --- a/frontend/src/services/api/tokenListSurfaces.ts +++ b/frontend/src/services/api/tokenListSurfaces.ts @@ -64,3 +64,15 @@ export async function fetchTokenListForSurface( export function resolveTokenListClientPath(surface: TokenListSurface, chainId = 138): string { return surface === 'extended' ? CONFIG_TOKEN_LIST_PATH : `/api/v1/report/token-list?chainId=${chainId}` } + +export async function loadTokenListResponseForSurface( + surface: TokenListSurface, + chainId = 138, +): Promise<{ response: { tokens: TokenListToken[] }; source: 'report' | 'config'; label: string }> { + const { tokens, source } = await fetchTokenListForSurface(surface, chainId) + return { + response: { tokens }, + source, + label: TOKEN_LIST_SURFACE_LABELS[surface], + } +} diff --git a/frontend/src/utils/visibilityRefresh.test.ts b/frontend/src/utils/visibilityRefresh.test.ts new file mode 100644 index 0000000..ffdba24 --- /dev/null +++ b/frontend/src/utils/visibilityRefresh.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' + +import { createVisibilityAwarePoller } from './visibilityRefresh' + +describe('createVisibilityAwarePoller', () => { + it('runs the task immediately on schedule and stops when cancelled', async () => { + let count = 0 + const stop = createVisibilityAwarePoller({ + intervalMs: 50, + task: () => { + count += 1 + }, + }) + + await new Promise((resolve) => setTimeout(resolve, 120)) + stop() + + expect(count).toBeGreaterThanOrEqual(2) + }) +}) diff --git a/frontend/src/utils/visibilityRefresh.ts b/frontend/src/utils/visibilityRefresh.ts new file mode 100644 index 0000000..41fc474 --- /dev/null +++ b/frontend/src/utils/visibilityRefresh.ts @@ -0,0 +1,43 @@ +export function createVisibilityAwarePoller(options: { + intervalMs: number + task: () => void | Promise +}): () => void { + let cancelled = false + let timeoutId: ReturnType | null = null + + const schedule = () => { + if (cancelled) return + if (timeoutId) clearTimeout(timeoutId) + timeoutId = setTimeout(async () => { + if (cancelled) return + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { + schedule() + return + } + try { + await options.task() + } finally { + schedule() + } + }, options.intervalMs) + } + + const onVisibility = () => { + if (!cancelled && typeof document !== 'undefined' && document.visibilityState === 'visible') { + void options.task() + } + } + + schedule() + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', onVisibility) + } + + return () => { + cancelled = true + if (timeoutId) clearTimeout(timeoutId) + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', onVisibility) + } + } +} diff --git a/scripts/e2e-sprint-smoke.spec.ts b/scripts/e2e-sprint-smoke.spec.ts index 4f0032b..81c0a5d 100644 --- a/scripts/e2e-sprint-smoke.spec.ts +++ b/scripts/e2e-sprint-smoke.spec.ts @@ -25,4 +25,9 @@ test.describe('Explorer sprint smoke', () => { await page.goto(`${EXPLORER_URL}/tokens/${CANONICAL_CUSDT}`, { waitUntil: 'domcontentloaded', timeout: 20000 }) await expect(page.getByText(/cUSDT|Tether/i).first()).toBeVisible({ timeout: 10000 }) }) + + test('operations hub loads extended token list note', async ({ page }) => { + await page.goto(`${EXPLORER_URL}/operations`, { waitUntil: 'domcontentloaded', timeout: 20000 }) + await expect(page.getByText(/Extended Metamask dual-chain catalog/i)).toBeVisible({ timeout: 10000 }) + }) })