Harden explorer MetaMask data and navigation coverage
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
{
|
||||
"name": "MetaMask Multi-Chain Networks (13 chains)",
|
||||
"version": {"major": 1, "minor": 2, "patch": 0},
|
||||
"defaultChainId": 138,
|
||||
"explorerUrl": "https://explorer.d-bis.org",
|
||||
"tokenListUrl": "https://explorer.d-bis.org/api/config/token-list",
|
||||
"generatedBy": "SolaceScanScout",
|
||||
"chains": [
|
||||
{"chainId":"0x8a","chainIdDecimal":138,"chainName":"DeFi Oracle Meta Mainnet","rpcUrls":["https://rpc-http-pub.d-bis.org","https://rpc.d-bis.org","https://rpc2.d-bis.org","https://rpc.defi-oracle.io"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://explorer.d-bis.org"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"]},
|
||||
{"chainId":"0x1","chainIdDecimal":1,"chainName":"Ethereum Mainnet","rpcUrls":["https://eth.llamarpc.com","https://rpc.ankr.com/eth","https://ethereum.publicnode.com","https://1rpc.io/eth"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://etherscan.io"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"]},
|
||||
{"chainId":"0x9f2c4","chainIdDecimal":651940,"chainName":"ALL Mainnet","rpcUrls":["https://mainnet-rpc.alltra.global"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://alltra.global"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"]},
|
||||
{"chainId":"0x8a","chainIdDecimal":138,"chainName":"DeFi Oracle Meta Mainnet","shortName":"dbis","rpcUrls":["https://rpc-http-pub.d-bis.org","https://rpc.d-bis.org","https://rpc2.d-bis.org","https://rpc.defi-oracle.io"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://explorer.d-bis.org"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://explorer.d-bis.org","explorerApiUrl":"https://explorer.d-bis.org/api/v2","testnet":false},
|
||||
{"chainId":"0x1","chainIdDecimal":1,"chainName":"Ethereum Mainnet","shortName":"eth","rpcUrls":["https://eth.llamarpc.com","https://rpc.ankr.com/eth","https://ethereum.publicnode.com","https://1rpc.io/eth"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://etherscan.io"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://ethereum.org","testnet":false},
|
||||
{"chainId":"0x9f2c4","chainIdDecimal":651940,"chainName":"ALL Mainnet","shortName":"all","rpcUrls":["https://mainnet-rpc.alltra.global"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://alltra.global"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://alltra.global","testnet":false},
|
||||
{"chainId":"0x19","chainIdDecimal":25,"chainName":"Cronos Mainnet","rpcUrls":["https://evm.cronos.org","https://cronos-rpc.publicnode.com"],"nativeCurrency":{"name":"CRO","symbol":"CRO","decimals":18},"blockExplorerUrls":["https://cronos.org/explorer"],"iconUrls":["https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong"]},
|
||||
{"chainId":"0x38","chainIdDecimal":56,"chainName":"BNB Smart Chain","rpcUrls":["https://bsc-dataseed.binance.org","https://bsc-dataseed1.defibit.io","https://bsc-dataseed1.ninicoin.io"],"nativeCurrency":{"name":"BNB","symbol":"BNB","decimals":18},"blockExplorerUrls":["https://bscscan.com"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"]},
|
||||
{"chainId":"0x64","chainIdDecimal":100,"chainName":"Gnosis Chain","rpcUrls":["https://rpc.gnosischain.com","https://gnosis-rpc.publicnode.com","https://1rpc.io/gnosis"],"nativeCurrency":{"name":"xDAI","symbol":"xDAI","decimals":18},"blockExplorerUrls":["https://gnosisscan.io"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"]},
|
||||
|
||||
@@ -7,6 +7,44 @@
|
||||
},
|
||||
"timestamp": "2026-03-26T09:17:26.866Z",
|
||||
"logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong",
|
||||
"keywords": [
|
||||
"chain138",
|
||||
"defi-oracle-meta",
|
||||
"multichain",
|
||||
"metamask",
|
||||
"wallet"
|
||||
],
|
||||
"tags": {
|
||||
"stablecoin": {
|
||||
"name": "Stablecoin",
|
||||
"description": "Fiat-pegged and fiat-mirrored assets published for explorer and wallet discovery."
|
||||
},
|
||||
"defi": {
|
||||
"name": "DeFi",
|
||||
"description": "Assets surfaced across the explorer and DEX route matrix."
|
||||
},
|
||||
"compliant": {
|
||||
"name": "Compliant",
|
||||
"description": "Compliance-oriented assets deployed on Chain 138."
|
||||
},
|
||||
"oracle": {
|
||||
"name": "Oracle",
|
||||
"description": "Oracle or oracle-adjacent assets and price feed entries."
|
||||
},
|
||||
"wrapped": {
|
||||
"name": "Wrapped",
|
||||
"description": "Wrapped representations of native or bridged assets."
|
||||
},
|
||||
"ccip": {
|
||||
"name": "CCIP",
|
||||
"description": "Assets related to CCIP and bridge infrastructure."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"defaultChainId": 138,
|
||||
"explorerUrl": "https://explorer.d-bis.org",
|
||||
"networksConfigUrl": "https://explorer.d-bis.org/api/config/networks"
|
||||
},
|
||||
"tokens": [
|
||||
{
|
||||
"chainId": 138,
|
||||
|
||||
163
backend/api/rest/config_test.go
Normal file
163
backend/api/rest/config_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testNetworksCatalog struct {
|
||||
Name string `json:"name"`
|
||||
DefaultChainID int `json:"defaultChainId"`
|
||||
ExplorerURL string `json:"explorerUrl"`
|
||||
TokenListURL string `json:"tokenListUrl"`
|
||||
GeneratedBy string `json:"generatedBy"`
|
||||
Chains []struct {
|
||||
ChainID string `json:"chainId"`
|
||||
ChainIDDecimal int `json:"chainIdDecimal"`
|
||||
ChainName string `json:"chainName"`
|
||||
ShortName string `json:"shortName"`
|
||||
RPCURLs []string `json:"rpcUrls"`
|
||||
BlockExplorerURL []string `json:"blockExplorerUrls"`
|
||||
InfoURL string `json:"infoURL"`
|
||||
ExplorerAPIURL string `json:"explorerApiUrl"`
|
||||
Testnet bool `json:"testnet"`
|
||||
} `json:"chains"`
|
||||
}
|
||||
|
||||
type testTokenList struct {
|
||||
Name string `json:"name"`
|
||||
Keywords []string
|
||||
Extensions struct {
|
||||
DefaultChainID int `json:"defaultChainId"`
|
||||
ExplorerURL string `json:"explorerUrl"`
|
||||
NetworksConfigURL string `json:"networksConfigUrl"`
|
||||
} `json:"extensions"`
|
||||
Tokens []struct {
|
||||
ChainID int `json:"chainId"`
|
||||
Address string `json:"address"`
|
||||
Symbol string `json:"symbol"`
|
||||
Decimals int `json:"decimals"`
|
||||
Extensions struct {
|
||||
UnitOfAccount string `json:"unitOfAccount"`
|
||||
UnitDescription string `json:"unitDescription"`
|
||||
} `json:"extensions"`
|
||||
} `json:"tokens"`
|
||||
}
|
||||
|
||||
func setupConfigHandler() http.Handler {
|
||||
server := NewServer(nil, 138)
|
||||
mux := http.NewServeMux()
|
||||
server.SetupRoutes(mux)
|
||||
return server.addMiddleware(mux)
|
||||
}
|
||||
|
||||
func TestConfigNetworksEndpointProvidesWalletMetadata(t *testing.T) {
|
||||
handler := setupConfigHandler()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/config/networks", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got == "" {
|
||||
t.Fatal("expected CORS header on config endpoint")
|
||||
}
|
||||
|
||||
var payload testNetworksCatalog
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("failed to parse networks payload: %v", err)
|
||||
}
|
||||
|
||||
if payload.DefaultChainID != 138 {
|
||||
t.Fatalf("expected defaultChainId 138, got %d", payload.DefaultChainID)
|
||||
}
|
||||
if payload.ExplorerURL == "" || payload.TokenListURL == "" || payload.GeneratedBy == "" {
|
||||
t.Fatal("expected root metadata fields to be populated")
|
||||
}
|
||||
if len(payload.Chains) < 3 {
|
||||
t.Fatalf("expected multiple chain entries, got %d", len(payload.Chains))
|
||||
}
|
||||
|
||||
var foundChain138 bool
|
||||
for _, chain := range payload.Chains {
|
||||
if chain.ChainIDDecimal != 138 {
|
||||
continue
|
||||
}
|
||||
foundChain138 = true
|
||||
if chain.ShortName == "" || chain.InfoURL == "" || chain.ExplorerAPIURL == "" {
|
||||
t.Fatal("expected Chain 138 optional metadata to be populated")
|
||||
}
|
||||
if len(chain.RPCURLs) == 0 || len(chain.BlockExplorerURL) == 0 {
|
||||
t.Fatal("expected Chain 138 RPC and explorer URLs")
|
||||
}
|
||||
if chain.Testnet {
|
||||
t.Fatal("expected Chain 138 to be marked as mainnet")
|
||||
}
|
||||
}
|
||||
|
||||
if !foundChain138 {
|
||||
t.Fatal("expected Chain 138 entry in networks catalog")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigTokenListEndpointProvidesOptionalMetadata(t *testing.T) {
|
||||
handler := setupConfigHandler()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/config/token-list", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var payload testTokenList
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("failed to parse token list payload: %v", err)
|
||||
}
|
||||
|
||||
if len(payload.Keywords) == 0 {
|
||||
t.Fatal("expected token list keywords")
|
||||
}
|
||||
if payload.Extensions.DefaultChainID != 138 || payload.Extensions.ExplorerURL == "" || payload.Extensions.NetworksConfigURL == "" {
|
||||
t.Fatal("expected root-level token list extensions")
|
||||
}
|
||||
|
||||
var foundCXAUC bool
|
||||
var foundCUSDT bool
|
||||
for _, token := range payload.Tokens {
|
||||
switch token.Symbol {
|
||||
case "cXAUC":
|
||||
foundCXAUC = true
|
||||
if token.Extensions.UnitOfAccount == "" || token.Extensions.UnitDescription == "" {
|
||||
t.Fatal("expected cXAUC optional unit metadata")
|
||||
}
|
||||
case "cUSDT":
|
||||
foundCUSDT = true
|
||||
if token.Decimals != 6 {
|
||||
t.Fatalf("expected cUSDT decimals 6, got %d", token.Decimals)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundCXAUC || !foundCUSDT {
|
||||
t.Fatal("expected cXAUC and cUSDT in token list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigEndpointsSupportOptionsPreflight(t *testing.T) {
|
||||
handler := setupConfigHandler()
|
||||
req := httptest.NewRequest(http.MethodOptions, "/api/config/token-list", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 preflight response, got %d", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Access-Control-Allow-Methods"); got == "" {
|
||||
t.Fatal("expected Access-Control-Allow-Methods header")
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,191 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
const CHAIN_138 = {
|
||||
type WalletChain = {
|
||||
chainId: string
|
||||
chainIdDecimal?: number
|
||||
chainName: string
|
||||
rpcUrls: string[]
|
||||
blockExplorerUrls: string[]
|
||||
iconUrls?: string[]
|
||||
nativeCurrency: {
|
||||
name: string
|
||||
symbol: string
|
||||
decimals: number
|
||||
}
|
||||
shortName?: string
|
||||
infoURL?: string
|
||||
explorerApiUrl?: string
|
||||
}
|
||||
|
||||
type TokenListToken = {
|
||||
chainId: number
|
||||
address: string
|
||||
name: string
|
||||
symbol: string
|
||||
decimals: number
|
||||
logoURI?: string
|
||||
tags?: string[]
|
||||
extensions?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type NetworksCatalog = {
|
||||
name?: string
|
||||
version?: {
|
||||
major?: number
|
||||
minor?: number
|
||||
patch?: number
|
||||
}
|
||||
defaultChainId?: number
|
||||
chains?: WalletChain[]
|
||||
}
|
||||
|
||||
type TokenListCatalog = {
|
||||
name?: string
|
||||
version?: {
|
||||
major?: number
|
||||
minor?: number
|
||||
patch?: number
|
||||
}
|
||||
keywords?: string[]
|
||||
tokens?: TokenListToken[]
|
||||
}
|
||||
|
||||
type EthereumProvider = {
|
||||
request: (args: { method: string; params?: unknown[] }) => Promise<unknown>
|
||||
}
|
||||
|
||||
const FALLBACK_CHAIN_138: WalletChain = {
|
||||
chainId: '0x8a',
|
||||
chainIdDecimal: 138,
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
rpcUrls: ['https://rpc-http-pub.d-bis.org', 'https://rpc.d-bis.org', 'https://rpc2.d-bis.org'],
|
||||
blockExplorerUrls: ['https://explorer.d-bis.org'],
|
||||
iconUrls: ['https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png'],
|
||||
shortName: 'dbis',
|
||||
infoURL: 'https://explorer.d-bis.org',
|
||||
explorerApiUrl: 'https://explorer.d-bis.org/api/v2',
|
||||
}
|
||||
|
||||
const CHAIN_MAINNET = {
|
||||
const FALLBACK_ETHEREUM: WalletChain = {
|
||||
chainId: '0x1',
|
||||
chainIdDecimal: 1,
|
||||
chainName: 'Ethereum Mainnet',
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
rpcUrls: ['https://eth.llamarpc.com', 'https://rpc.ankr.com/eth', 'https://ethereum.publicnode.com'],
|
||||
blockExplorerUrls: ['https://etherscan.io'],
|
||||
iconUrls: ['https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png'],
|
||||
shortName: 'eth',
|
||||
infoURL: 'https://ethereum.org',
|
||||
}
|
||||
|
||||
const CHAIN_ALL_MAINNET = {
|
||||
const FALLBACK_ALL_MAINNET: WalletChain = {
|
||||
chainId: '0x9f2c4',
|
||||
chainIdDecimal: 651940,
|
||||
chainName: 'ALL Mainnet',
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
rpcUrls: ['https://mainnet-rpc.alltra.global'],
|
||||
blockExplorerUrls: ['https://alltra.global'],
|
||||
iconUrls: ['https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png'],
|
||||
shortName: 'all',
|
||||
infoURL: 'https://alltra.global',
|
||||
}
|
||||
|
||||
const FEATURED_TOKEN_SYMBOLS = ['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAUT']
|
||||
|
||||
function getApiBase() {
|
||||
if (typeof window !== 'undefined') {
|
||||
return process.env.NEXT_PUBLIC_API_URL || window.location.origin
|
||||
}
|
||||
return process.env.NEXT_PUBLIC_API_URL || 'https://explorer.d-bis.org'
|
||||
}
|
||||
|
||||
export function AddToMetaMask() {
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [networks, setNetworks] = useState<NetworksCatalog | null>(null)
|
||||
const [tokenList, setTokenList] = useState<TokenListCatalog | null>(null)
|
||||
|
||||
const ethereum = typeof window !== 'undefined' ? (window as unknown as { ethereum?: { request: (args: { method: string; params: unknown[] }) => Promise<unknown> } }).ethereum : undefined
|
||||
const ethereum = typeof window !== 'undefined'
|
||||
? (window as unknown as { ethereum?: EthereumProvider }).ethereum
|
||||
: undefined
|
||||
|
||||
const addChain = async (chain: typeof CHAIN_138) => {
|
||||
const apiBase = getApiBase().replace(/\/$/, '')
|
||||
const tokenListUrl = `${apiBase}/api/config/token-list`
|
||||
const networksUrl = `${apiBase}/api/config/networks`
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
async function loadCatalogs() {
|
||||
try {
|
||||
const [networksResponse, tokenListResponse] = await Promise.all([
|
||||
fetch(networksUrl),
|
||||
fetch(tokenListUrl),
|
||||
])
|
||||
|
||||
const [networksJson, tokenListJson] = await Promise.all([
|
||||
networksResponse.ok ? networksResponse.json() : null,
|
||||
tokenListResponse.ok ? tokenListResponse.json() : null,
|
||||
])
|
||||
|
||||
if (!active) return
|
||||
setNetworks(networksJson)
|
||||
setTokenList(tokenListJson)
|
||||
} catch {
|
||||
if (!active) return
|
||||
setNetworks(null)
|
||||
setTokenList(null)
|
||||
}
|
||||
}
|
||||
|
||||
loadCatalogs()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [networksUrl, tokenListUrl])
|
||||
|
||||
const chains = useMemo(() => {
|
||||
const chainMap = new Map<number, WalletChain>()
|
||||
for (const chain of networks?.chains || []) {
|
||||
if (typeof chain.chainIdDecimal === 'number') {
|
||||
chainMap.set(chain.chainIdDecimal, chain)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
chain138: chainMap.get(138) || FALLBACK_CHAIN_138,
|
||||
ethereum: chainMap.get(1) || FALLBACK_ETHEREUM,
|
||||
allMainnet: chainMap.get(651940) || FALLBACK_ALL_MAINNET,
|
||||
total: (networks?.chains || []).length,
|
||||
}
|
||||
}, [networks])
|
||||
|
||||
const featuredTokens = useMemo(() => {
|
||||
const tokenMap = new Map<string, TokenListToken>()
|
||||
for (const token of tokenList?.tokens || []) {
|
||||
if (token.chainId !== 138) continue
|
||||
if (!FEATURED_TOKEN_SYMBOLS.includes(token.symbol)) continue
|
||||
tokenMap.set(token.symbol, token)
|
||||
}
|
||||
|
||||
return FEATURED_TOKEN_SYMBOLS
|
||||
.map((symbol) => tokenMap.get(symbol))
|
||||
.filter((token): token is TokenListToken => !!token)
|
||||
}, [tokenList])
|
||||
|
||||
const addChain = async (chain: WalletChain) => {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
|
||||
if (!ethereum) {
|
||||
setError('MetaMask or another Web3 wallet is not installed.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ethereum.request({
|
||||
method: 'wallet_addEthereumChain',
|
||||
@@ -50,53 +197,168 @@ export function AddToMetaMask() {
|
||||
if (err.code === 4902) {
|
||||
setStatus(`Added ${chain.chainName}. You can switch to it in your wallet.`)
|
||||
} else {
|
||||
setError(err.message || 'Failed to add network')
|
||||
setError(err.message || `Failed to add ${chain.chainName}.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Production (explorer.d-bis.org): same origin; dev: NEXT_PUBLIC_API_URL or localhost
|
||||
const apiBase =
|
||||
typeof window !== 'undefined'
|
||||
? process.env.NEXT_PUBLIC_API_URL || window.location.origin
|
||||
: process.env.NEXT_PUBLIC_API_URL || 'https://explorer.d-bis.org'
|
||||
const tokenListUrl = apiBase.replace(/\/$/, '') + '/api/config/token-list'
|
||||
const watchToken = async (token: TokenListToken) => {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
|
||||
if (!ethereum) {
|
||||
setError('MetaMask or another Web3 wallet is not installed.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const added = await ethereum.request({
|
||||
method: 'wallet_watchAsset',
|
||||
params: [{
|
||||
type: 'ERC20',
|
||||
options: {
|
||||
address: token.address,
|
||||
symbol: token.symbol,
|
||||
decimals: token.decimals,
|
||||
image: token.logoURI,
|
||||
},
|
||||
}],
|
||||
})
|
||||
|
||||
setStatus(added ? `Added ${token.symbol} to your wallet.` : `${token.symbol} request was dismissed.`)
|
||||
} catch (e) {
|
||||
const err = e as { message?: string }
|
||||
setError(err.message || `Failed to add ${token.symbol}.`)
|
||||
}
|
||||
}
|
||||
|
||||
const copyText = async (value: string, label: string) => {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
setStatus(`Copied ${label}.`)
|
||||
} catch {
|
||||
setError(`Could not copy ${label}.`)
|
||||
}
|
||||
}
|
||||
|
||||
const tokenCount138 = (tokenList?.tokens || []).filter((token) => token.chainId === 138).length
|
||||
const metadataKeywordString = (tokenList?.keywords || []).join(', ')
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 space-y-4">
|
||||
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 className="text-lg font-semibold">Add to MetaMask</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Add Chain 138 (DeFi Oracle Meta Mainnet), Ethereum Mainnet, or ALL Mainnet to your wallet. Then add the token list URL in MetaMask Settings so tokens appear automatically.
|
||||
The wallet tools now read the same explorer-served network catalog and token list that MetaMask can consume.
|
||||
That keeps chain metadata, token metadata, and optional extensions aligned with the live explorer API instead of
|
||||
relying on stale frontend-only defaults.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addChain(CHAIN_138)}
|
||||
className="px-4 py-2 rounded bg-primary-600 text-white hover:bg-primary-700 text-sm font-medium"
|
||||
onClick={() => addChain(chains.chain138)}
|
||||
className="rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
|
||||
>
|
||||
Add Chain 138
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addChain(CHAIN_MAINNET)}
|
||||
className="px-4 py-2 rounded bg-gray-600 text-white hover:bg-gray-700 text-sm font-medium"
|
||||
onClick={() => addChain(chains.ethereum)}
|
||||
className="rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
|
||||
>
|
||||
Add Ethereum Mainnet
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addChain(CHAIN_ALL_MAINNET)}
|
||||
className="px-4 py-2 rounded bg-gray-600 text-white hover:bg-gray-700 text-sm font-medium"
|
||||
onClick={() => addChain(chains.allMainnet)}
|
||||
className="rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
|
||||
>
|
||||
Add ALL Mainnet
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-1">Token list URL (add in MetaMask Settings → Token lists):</p>
|
||||
<code className="block p-2 rounded bg-gray-100 dark:bg-gray-900 break-all text-xs">{tokenListUrl}</code>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Explorer-served MetaMask metadata</div>
|
||||
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>Networks catalog: {chains.total > 0 ? `${chains.total} chains` : 'using frontend fallback values'}</p>
|
||||
<p>Chain 138 token entries: {tokenCount138}</p>
|
||||
{metadataKeywordString ? <p>Keywords: {metadataKeywordString}</p> : null}
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Networks config URL</p>
|
||||
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{networksUrl}</code>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => copyText(networksUrl, 'networks config URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
Copy URL
|
||||
</button>
|
||||
<a href={networksUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
Open JSON
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token list URL</p>
|
||||
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{tokenListUrl}</code>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => copyText(tokenListUrl, 'token list URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
Copy URL
|
||||
</button>
|
||||
<a href={tokenListUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
Open JSON
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Featured Chain 138 tokens</div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
These tokens come from the explorer token list and use `wallet_watchAsset` so the wallet gets the same symbol,
|
||||
decimals, image, and optional token metadata that the explorer publishes.
|
||||
</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{featuredTokens.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Featured token metadata is not available right now.</p>
|
||||
) : featuredTokens.map((token) => (
|
||||
<div key={token.address} className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{token.symbol} <span className="font-normal text-gray-500 dark:text-gray-400">({token.name})</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{token.address}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Decimals: {token.decimals}
|
||||
{token.tags?.length ? ` • Tags: ${token.tags.join(', ')}` : ''}
|
||||
</div>
|
||||
{typeof token.extensions?.unitDescription === 'string' ? (
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{token.extensions.unitDescription}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => watchToken(token)}
|
||||
className="rounded bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700"
|
||||
>
|
||||
Add {token.symbol}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{status && <p className="text-sm text-green-600 dark:text-green-400">{status}</p>}
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
|
||||
{status ? <p className="text-sm text-green-600 dark:text-green-400">{status}</p> : null}
|
||||
{error ? <p className="text-sm text-red-600 dark:text-red-400">{error}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -115,6 +115,15 @@ export default function AddressDetailPage() {
|
||||
{addressInfo.label || 'Address'}
|
||||
</h1>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/addresses" className="text-primary-600 hover:underline">
|
||||
Back to addresses
|
||||
</Link>
|
||||
<Link href={`/search?q=${encodeURIComponent(addressInfo.address)}`} className="text-primary-600 hover:underline">
|
||||
Search this address
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card title="Address Information" className="mb-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
|
||||
@@ -60,6 +60,20 @@ export default function BlockDetailPage() {
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Block #{block.number}</h1>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/blocks" className="text-primary-600 hover:underline">
|
||||
Back to blocks
|
||||
</Link>
|
||||
{block.number > 0 ? (
|
||||
<Link href={`/blocks/${block.number - 1}`} className="text-primary-600 hover:underline">
|
||||
Previous block
|
||||
</Link>
|
||||
) : null}
|
||||
<Link href={`/blocks/${block.number + 1}`} className="text-primary-600 hover:underline">
|
||||
Next block
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card title="Block Information">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
|
||||
@@ -61,6 +61,15 @@ export default function TransactionDetailPage() {
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Transaction</h1>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/transactions" className="text-primary-600 hover:underline">
|
||||
Back to transactions
|
||||
</Link>
|
||||
<Link href={`/search?q=${encodeURIComponent(transaction.hash)}`} className="text-primary-600 hover:underline">
|
||||
Search this hash
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card title="Transaction Information">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
|
||||
@@ -1,121 +1,85 @@
|
||||
/**
|
||||
* Explorer Frontend E2E Tests
|
||||
* Tests all links and path-based routing on explorer.d-bis.org
|
||||
* Tests live SPA links, route coverage, and detail-page navigation on explorer.d-bis.org.
|
||||
* Run: npx playwright test explorer-monorepo/scripts/e2e-explorer-frontend.spec.ts --project=chromium
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
const EXPLORER_URL = process.env.EXPLORER_URL || 'https://explorer.d-bis.org';
|
||||
const ADDRESS_TEST = '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506';
|
||||
const EXPLORER_URL = process.env.EXPLORER_URL || 'https://explorer.d-bis.org'
|
||||
const ADDRESS_TEST = '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506'
|
||||
|
||||
test.describe('Explorer Frontend - Path-Based URLs', () => {
|
||||
test('address path /address/0x... loads address detail', async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}/address/${ADDRESS_TEST}`, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
const hasBreadcrumb = await page.locator('#addressDetailBreadcrumb').count() > 0;
|
||||
const bodyText = await page.locator('body').textContent().catch(() => '') || '';
|
||||
expect(hasBreadcrumb || bodyText.length > 100).toBe(true);
|
||||
expect(bodyText).toMatch(/Address|Balance|Transaction|0x99|Explorer|detail/i);
|
||||
});
|
||||
async function expectBodyToContain(page: Parameters<typeof test>[0], pattern: RegExp) {
|
||||
const bodyText = await page.locator('body').textContent().catch(() => '') || ''
|
||||
expect(bodyText).toMatch(pattern)
|
||||
}
|
||||
|
||||
test('root path loads homepage', async ({ page }) => {
|
||||
await page.goto(EXPLORER_URL, { waitUntil: 'load', timeout: 25000 });
|
||||
// Logo and nav are always in the HTML; home view or stats appear once SPA runs
|
||||
const body = await page.locator('body').textContent();
|
||||
expect(body).toMatch(/SolaceScanScout|Explorer|Chain|Block|Transaction/i);
|
||||
});
|
||||
test.describe('Explorer Frontend - Path Coverage', () => {
|
||||
for (const route of [
|
||||
{ path: '/', matcher: /SolaceScanScout|Latest Blocks|Explorer/i },
|
||||
{ path: '/blocks', matcher: /Blocks|Block/i },
|
||||
{ path: '/transactions', matcher: /Transactions|Transaction/i },
|
||||
{ path: '/addresses', matcher: /Addresses|Address/i },
|
||||
{ path: '/watchlist', matcher: /Watchlist|Explorer/i },
|
||||
{ path: '/pools', matcher: /Pools|Liquidity/i },
|
||||
{ path: '/liquidity', matcher: /Liquidity|Route|Pool/i },
|
||||
{ path: '/bridge', matcher: /Bridge|Explorer/i },
|
||||
{ path: '/weth', matcher: /WETH|Explorer/i },
|
||||
]) {
|
||||
test(`${route.path} loads`, async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}${route.path}`, { waitUntil: 'networkidle', timeout: 20000 })
|
||||
await expect(page).toHaveURL(new RegExp(route.path === '/' ? '/?$' : route.path.replace('/', '\\/')), { timeout: 8000 })
|
||||
await expectBodyToContain(page, route.matcher)
|
||||
})
|
||||
}
|
||||
|
||||
test('blocks path /blocks loads blocks list', async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}/blocks`, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
await expect(page.locator('text=Block').first()).toBeVisible({ timeout: 8000 });
|
||||
});
|
||||
test('/address/:address loads address detail', async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}/address/${ADDRESS_TEST}`, { waitUntil: 'networkidle', timeout: 20000 })
|
||||
await page.waitForSelector('#addressDetailBreadcrumb', { state: 'attached', timeout: 15000 })
|
||||
await expectBodyToContain(page, /Address|Balance|Transaction|Explorer/i)
|
||||
})
|
||||
})
|
||||
|
||||
test('transactions path /transactions loads transactions list', async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}/transactions`, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
await expect(page.locator('text=Transaction').first()).toBeVisible({ timeout: 8000 });
|
||||
});
|
||||
test.describe('Explorer Frontend - Nav and Detail Links', () => {
|
||||
test('MetaMask Snap link is present', async ({ page }) => {
|
||||
await page.goto(EXPLORER_URL, { waitUntil: 'domcontentloaded', timeout: 20000 })
|
||||
const snapLink = page.locator('a[href="/snap/"]').first()
|
||||
await expect(snapLink).toBeVisible({ timeout: 8000 })
|
||||
await expect(snapLink).toHaveAttribute('href', '/snap/')
|
||||
})
|
||||
|
||||
test('bridge path /bridge loads and shows bridge or explorer', async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}/bridge`, { waitUntil: 'networkidle', timeout: 20000 });
|
||||
const url = page.url();
|
||||
const body = await page.locator('body').textContent();
|
||||
expect(url).toMatch(/\/bridge/);
|
||||
expect(body).toMatch(/Bridge|SolaceScanScout|Explorer/i);
|
||||
});
|
||||
test('Address breadcrumb home link returns to root', async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}/address/${ADDRESS_TEST}`, { waitUntil: 'networkidle', timeout: 20000 })
|
||||
const homeLink = page.locator('#addressDetailBreadcrumb a[href="/"]').first()
|
||||
await expect(homeLink).toBeVisible({ timeout: 8000 })
|
||||
await homeLink.click()
|
||||
await expect(page).toHaveURL(new RegExp(`${EXPLORER_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/?$`), { timeout: 8000 })
|
||||
})
|
||||
|
||||
test('weth path /weth loads and shows WETH or explorer', async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}/weth`, { waitUntil: 'networkidle', timeout: 20000 });
|
||||
const url = page.url();
|
||||
const body = await page.locator('body').textContent();
|
||||
expect(url).toMatch(/\/weth/);
|
||||
expect(body).toMatch(/WETH|SolaceScanScout|Explorer/i);
|
||||
});
|
||||
test('Blocks list opens block detail view', async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}/blocks`, { waitUntil: 'networkidle', timeout: 20000 })
|
||||
const blockLink = page.locator('[onclick*="showBlockDetail"]').first()
|
||||
await expect(blockLink).toBeVisible({ timeout: 8000 })
|
||||
await blockLink.click()
|
||||
await expect(page.locator('#blockDetailView.active')).toBeVisible({ timeout: 8000 })
|
||||
await expect(page.locator('#blockDetailBreadcrumb')).toBeVisible({ timeout: 8000 })
|
||||
})
|
||||
|
||||
test('watchlist path /watchlist loads and shows watchlist or explorer', async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}/watchlist`, { waitUntil: 'networkidle', timeout: 20000 });
|
||||
const url = page.url();
|
||||
const body = await page.locator('body').textContent();
|
||||
expect(url).toMatch(/\/watchlist/);
|
||||
expect(body).toMatch(/Watchlist|SolaceScanScout|Explorer/i);
|
||||
});
|
||||
});
|
||||
test('Transactions list opens transaction detail view', async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}/transactions`, { waitUntil: 'networkidle', timeout: 20000 })
|
||||
const transactionLink = page.locator('[onclick*="showTransactionDetail"]').first()
|
||||
await expect(transactionLink).toBeVisible({ timeout: 8000 })
|
||||
await transactionLink.click()
|
||||
await expect(page.locator('#transactionDetailView.active')).toBeVisible({ timeout: 8000 })
|
||||
await expect(page.locator('#transactionDetailBreadcrumb')).toBeVisible({ timeout: 8000 })
|
||||
})
|
||||
|
||||
test.describe('Explorer Frontend - Nav Links (path-based routing)', () => {
|
||||
test('Root shows home content', async ({ page }) => {
|
||||
await page.goto(EXPLORER_URL, { waitUntil: 'load', timeout: 20000 });
|
||||
await expect(page.locator('#homeView').or(page.getByText('Latest Blocks')).first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('Blocks route /blocks loads', async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}/blocks`, { waitUntil: 'load', timeout: 20000 });
|
||||
await expect(page).toHaveURL(/\/blocks/, { timeout: 5000 });
|
||||
await expect(page.getByText('Block').first()).toBeVisible({ timeout: 8000 });
|
||||
});
|
||||
|
||||
test('Transactions route /transactions loads', async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}/transactions`, { waitUntil: 'load', timeout: 20000 });
|
||||
await expect(page).toHaveURL(/\/transactions/, { timeout: 5000 });
|
||||
await expect(page.getByText('Transaction').first()).toBeVisible({ timeout: 8000 });
|
||||
});
|
||||
|
||||
test('Bridge route /bridge has correct URL', async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}/bridge`, { waitUntil: 'load', timeout: 20000 });
|
||||
await expect(page).toHaveURL(/\/bridge/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('WETH route /weth has correct URL', async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}/weth`, { waitUntil: 'load', timeout: 20000 });
|
||||
await expect(page).toHaveURL(/\/weth/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('MetaMask Snap link has correct href', async ({ page }) => {
|
||||
await page.goto(EXPLORER_URL, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
||||
const snapLink = page.locator('a[href="/snap/"]').first();
|
||||
await expect(snapLink).toBeVisible({ timeout: 8000 });
|
||||
await expect(snapLink).toHaveAttribute('href', '/snap/');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Explorer Frontend - Breadcrumbs & Detail Links', () => {
|
||||
test('Address page breadcrumb links work', async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}/address/${ADDRESS_TEST}`, { waitUntil: 'networkidle', timeout: 15000 });
|
||||
await page.waitForSelector('#addressDetailBreadcrumb', { state: 'attached', timeout: 15000 });
|
||||
const homeLink = page.locator('#addressDetailBreadcrumb a[href="/home"], #addressDetailBreadcrumb a[href="#/home"]').first();
|
||||
if (await homeLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await homeLink.click();
|
||||
await page.waitForTimeout(500);
|
||||
expect(page.url()).toContain('home');
|
||||
}
|
||||
});
|
||||
|
||||
test('Block number link from list opens block detail', async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}/blocks`, { waitUntil: 'networkidle', timeout: 15000 });
|
||||
const blockLink = page.locator('a[href^="#/block/"], [onclick*="showBlockDetail"]').first();
|
||||
if (await blockLink.isVisible({ timeout: 3000 })) {
|
||||
await blockLink.click();
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.locator('#blockDetail, .block-detail')).toBeVisible({ timeout: 3000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
test('Addresses list opens address detail view', async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}/addresses`, { waitUntil: 'networkidle', timeout: 20000 })
|
||||
const addressLink = page.locator('[onclick*="showAddressDetail"]').first()
|
||||
await expect(addressLink).toBeVisible({ timeout: 8000 })
|
||||
await addressLink.click()
|
||||
await expect(page.locator('#addressDetailView.active')).toBeVisible({ timeout: 8000 })
|
||||
await expect(page.locator('#addressDetailBreadcrumb')).toBeVisible({ timeout: 8000 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user