Compare commits
1 Commits
devin/1776
...
devin/1776
| Author | SHA1 | Date | |
|---|---|---|---|
| 174cbfde04 |
71
.github/workflows/e2e-full.yml
vendored
Normal file
71
.github/workflows/e2e-full.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
name: e2e-full
|
||||
|
||||
# Boots the full explorer stack (docker-compose deps + backend + frontend)
|
||||
# and runs the Playwright full-stack smoke spec against it. Not on every
|
||||
# PR (too expensive) — runs on:
|
||||
#
|
||||
# * workflow_dispatch (manual)
|
||||
# * pull_request when the 'run-e2e-full' label is applied
|
||||
# * nightly at 04:00 UTC
|
||||
#
|
||||
# Screenshots from every route are uploaded as a build artefact so
|
||||
# reviewers can eyeball the render without having to boot the stack.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [labeled, opened, synchronize, reopened]
|
||||
schedule:
|
||||
- cron: '0 4 * * *'
|
||||
|
||||
jobs:
|
||||
e2e-full:
|
||||
if: >
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event_name == 'schedule' ||
|
||||
(github.event_name == 'pull_request' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'run-e2e-full'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23.x'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install root Playwright dependency
|
||||
run: npm ci --no-audit --no-fund --prefix .
|
||||
|
||||
- name: Run full-stack e2e
|
||||
env:
|
||||
JWT_SECRET: ${{ secrets.JWT_SECRET || 'ci-ephemeral-jwt-secret-not-for-prod' }}
|
||||
CSP_HEADER: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' http://localhost:8080 ws://localhost:8080"
|
||||
run: make e2e-full
|
||||
|
||||
- name: Upload screenshots
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-screenshots
|
||||
path: test-results/screenshots/
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
playwright-report/
|
||||
test-results/
|
||||
if-no-files-found: warn
|
||||
6
Makefile
6
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: help install dev build test test-e2e clean migrate
|
||||
.PHONY: help install dev build test test-e2e e2e-full clean migrate
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@@ -7,6 +7,7 @@ help:
|
||||
@echo " build - Build all services"
|
||||
@echo " test - Run backend + frontend tests (go test, lint, type-check)"
|
||||
@echo " test-e2e - Run Playwright E2E tests (default: explorer.d-bis.org)"
|
||||
@echo " e2e-full - Boot full stack locally (docker compose + backend + frontend) and run Playwright"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo " migrate - Run database migrations"
|
||||
|
||||
@@ -35,6 +36,9 @@ test:
|
||||
test-e2e:
|
||||
npx playwright test
|
||||
|
||||
e2e-full:
|
||||
./scripts/e2e-full.sh
|
||||
|
||||
clean:
|
||||
cd backend && go clean ./...
|
||||
cd frontend && rm -rf .next node_modules
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,381 +0,0 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
addressPattern = regexp.MustCompile(`0x[a-fA-F0-9]{40}`)
|
||||
transactionPattern = regexp.MustCompile(`0x[a-fA-F0-9]{64}`)
|
||||
blockRefPattern = regexp.MustCompile(`(?i)\bblock\s+#?(\d+)\b`)
|
||||
)
|
||||
|
||||
func (s *Server) buildAIContext(ctx context.Context, query string, pageContext map[string]string) (AIContextEnvelope, []string) {
|
||||
warnings := []string{}
|
||||
envelope := AIContextEnvelope{
|
||||
ChainID: s.chainID,
|
||||
Explorer: "SolaceScan",
|
||||
PageContext: compactStringMap(pageContext),
|
||||
CapabilityNotice: "This assistant is wired for read-only explorer analysis. It can summarize indexed chain data, liquidity routes, and curated workspace docs, but it does not sign transactions or execute private operations.",
|
||||
}
|
||||
|
||||
sources := []AIContextSource{
|
||||
{Type: "system", Label: "Explorer REST backend"},
|
||||
}
|
||||
|
||||
if stats, err := s.queryAIStats(ctx); err == nil {
|
||||
envelope.Stats = stats
|
||||
sources = append(sources, AIContextSource{Type: "database", Label: "Explorer indexer database"})
|
||||
} else if err != nil {
|
||||
warnings = append(warnings, "indexed explorer stats unavailable: "+err.Error())
|
||||
}
|
||||
|
||||
if strings.TrimSpace(query) != "" {
|
||||
if txHash := firstRegexMatch(transactionPattern, query); txHash != "" && s.db != nil {
|
||||
if tx, err := s.queryAITransaction(ctx, txHash); err == nil && len(tx) > 0 {
|
||||
envelope.Transaction = tx
|
||||
} else if err != nil {
|
||||
warnings = append(warnings, "transaction context unavailable: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if addr := firstRegexMatch(addressPattern, query); addr != "" && s.db != nil {
|
||||
if addressInfo, err := s.queryAIAddress(ctx, addr); err == nil && len(addressInfo) > 0 {
|
||||
envelope.Address = addressInfo
|
||||
} else if err != nil {
|
||||
warnings = append(warnings, "address context unavailable: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if blockNumber := extractBlockReference(query); blockNumber > 0 && s.db != nil {
|
||||
if block, err := s.queryAIBlock(ctx, blockNumber); err == nil && len(block) > 0 {
|
||||
envelope.Block = block
|
||||
} else if err != nil {
|
||||
warnings = append(warnings, "block context unavailable: "+err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if routeMatches, routeWarning := s.queryAIRoutes(ctx, query); len(routeMatches) > 0 {
|
||||
envelope.RouteMatches = routeMatches
|
||||
sources = append(sources, AIContextSource{Type: "routes", Label: "Token aggregation live routes", Origin: firstNonEmptyEnv("TOKEN_AGGREGATION_API_BASE", "TOKEN_AGGREGATION_URL", "TOKEN_AGGREGATION_BASE_URL")})
|
||||
} else if routeWarning != "" {
|
||||
warnings = append(warnings, routeWarning)
|
||||
}
|
||||
|
||||
if docs, root, docWarning := loadAIDocSnippets(query); len(docs) > 0 {
|
||||
envelope.DocSnippets = docs
|
||||
sources = append(sources, AIContextSource{Type: "docs", Label: "Workspace docs", Origin: root})
|
||||
} else if docWarning != "" {
|
||||
warnings = append(warnings, docWarning)
|
||||
}
|
||||
|
||||
envelope.Sources = sources
|
||||
return envelope, uniqueStrings(warnings)
|
||||
}
|
||||
|
||||
func (s *Server) queryAIStats(ctx context.Context) (map[string]any, error) {
|
||||
if s.db == nil {
|
||||
return nil, fmt.Errorf("database unavailable")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stats := map[string]any{}
|
||||
|
||||
var totalBlocks int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM blocks WHERE chain_id = $1`, s.chainID).Scan(&totalBlocks); err == nil {
|
||||
stats["total_blocks"] = totalBlocks
|
||||
}
|
||||
|
||||
var totalTransactions int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM transactions WHERE chain_id = $1`, s.chainID).Scan(&totalTransactions); err == nil {
|
||||
stats["total_transactions"] = totalTransactions
|
||||
}
|
||||
|
||||
var totalAddresses int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM (
|
||||
SELECT from_address AS address
|
||||
FROM transactions
|
||||
WHERE chain_id = $1 AND from_address IS NOT NULL AND from_address <> ''
|
||||
UNION
|
||||
SELECT to_address AS address
|
||||
FROM transactions
|
||||
WHERE chain_id = $1 AND to_address IS NOT NULL AND to_address <> ''
|
||||
) unique_addresses`, s.chainID).Scan(&totalAddresses); err == nil {
|
||||
stats["total_addresses"] = totalAddresses
|
||||
}
|
||||
|
||||
var latestBlock int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(number), 0) FROM blocks WHERE chain_id = $1`, s.chainID).Scan(&latestBlock); err == nil {
|
||||
stats["latest_block"] = latestBlock
|
||||
}
|
||||
|
||||
if len(stats) == 0 {
|
||||
var totalBlocks int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM blocks`).Scan(&totalBlocks); err == nil {
|
||||
stats["total_blocks"] = totalBlocks
|
||||
}
|
||||
|
||||
var totalTransactions int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM transactions`).Scan(&totalTransactions); err == nil {
|
||||
stats["total_transactions"] = totalTransactions
|
||||
}
|
||||
|
||||
var totalAddresses int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM addresses`).Scan(&totalAddresses); err == nil {
|
||||
stats["total_addresses"] = totalAddresses
|
||||
}
|
||||
|
||||
var latestBlock int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(number), 0) FROM blocks`).Scan(&latestBlock); err == nil {
|
||||
stats["latest_block"] = latestBlock
|
||||
}
|
||||
}
|
||||
|
||||
if len(stats) == 0 {
|
||||
return nil, fmt.Errorf("no indexed stats available")
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *Server) queryAITransaction(ctx context.Context, hash string) (map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
|
||||
defer cancel()
|
||||
|
||||
query := `
|
||||
SELECT hash, block_number, from_address, to_address, value, gas_used, gas_price, status, timestamp_iso
|
||||
FROM transactions
|
||||
WHERE chain_id = $1 AND hash = $2
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
var txHash, fromAddress, value string
|
||||
var blockNumber int64
|
||||
var toAddress *string
|
||||
var gasUsed, gasPrice *int64
|
||||
var status *int64
|
||||
var timestampISO *string
|
||||
|
||||
err := s.db.QueryRow(ctx, query, s.chainID, hash).Scan(
|
||||
&txHash, &blockNumber, &fromAddress, &toAddress, &value, &gasUsed, &gasPrice, &status, ×tampISO,
|
||||
)
|
||||
if err != nil {
|
||||
normalizedHash := normalizeHexString(hash)
|
||||
blockscoutQuery := `
|
||||
SELECT
|
||||
concat('0x', encode(hash, 'hex')) AS hash,
|
||||
block_number,
|
||||
concat('0x', encode(from_address_hash, 'hex')) AS from_address,
|
||||
CASE
|
||||
WHEN to_address_hash IS NULL THEN NULL
|
||||
ELSE concat('0x', encode(to_address_hash, 'hex'))
|
||||
END AS to_address,
|
||||
COALESCE(value::text, '0') AS value,
|
||||
gas_used,
|
||||
gas_price,
|
||||
status,
|
||||
TO_CHAR(block_timestamp AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS timestamp_iso
|
||||
FROM transactions
|
||||
WHERE hash = decode($1, 'hex')
|
||||
LIMIT 1
|
||||
`
|
||||
if fallbackErr := s.db.QueryRow(ctx, blockscoutQuery, normalizedHash).Scan(
|
||||
&txHash, &blockNumber, &fromAddress, &toAddress, &value, &gasUsed, &gasPrice, &status, ×tampISO,
|
||||
); fallbackErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
tx := map[string]any{
|
||||
"hash": txHash,
|
||||
"block_number": blockNumber,
|
||||
"from_address": fromAddress,
|
||||
"value": value,
|
||||
}
|
||||
if toAddress != nil {
|
||||
tx["to_address"] = *toAddress
|
||||
}
|
||||
if gasUsed != nil {
|
||||
tx["gas_used"] = *gasUsed
|
||||
}
|
||||
if gasPrice != nil {
|
||||
tx["gas_price"] = *gasPrice
|
||||
}
|
||||
if status != nil {
|
||||
tx["status"] = *status
|
||||
}
|
||||
if timestampISO != nil {
|
||||
tx["timestamp_iso"] = *timestampISO
|
||||
}
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
func (s *Server) queryAIAddress(ctx context.Context, address string) (map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
|
||||
defer cancel()
|
||||
|
||||
address = normalizeAddress(address)
|
||||
|
||||
result := map[string]any{
|
||||
"address": address,
|
||||
}
|
||||
|
||||
var txCount int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM transactions WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(to_address) = $2)`, s.chainID, address).Scan(&txCount); err == nil {
|
||||
result["transaction_count"] = txCount
|
||||
}
|
||||
|
||||
var tokenCount int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(DISTINCT token_contract) FROM token_transfers WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(to_address) = $2)`, s.chainID, address).Scan(&tokenCount); err == nil {
|
||||
result["token_count"] = tokenCount
|
||||
}
|
||||
|
||||
var recentHashes []string
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT hash
|
||||
FROM transactions
|
||||
WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(to_address) = $2)
|
||||
ORDER BY block_number DESC, transaction_index DESC
|
||||
LIMIT 5
|
||||
`, s.chainID, address)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var hash string
|
||||
if scanErr := rows.Scan(&hash); scanErr == nil {
|
||||
recentHashes = append(recentHashes, hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(recentHashes) > 0 {
|
||||
result["recent_transactions"] = recentHashes
|
||||
}
|
||||
|
||||
if len(result) == 1 {
|
||||
normalizedAddress := normalizeHexString(address)
|
||||
|
||||
var blockscoutTxCount int64
|
||||
var blockscoutTokenCount int64
|
||||
blockscoutAddressQuery := `
|
||||
SELECT
|
||||
COALESCE(transactions_count, 0),
|
||||
COALESCE(token_transfers_count, 0)
|
||||
FROM addresses
|
||||
WHERE hash = decode($1, 'hex')
|
||||
LIMIT 1
|
||||
`
|
||||
if err := s.db.QueryRow(ctx, blockscoutAddressQuery, normalizedAddress).Scan(&blockscoutTxCount, &blockscoutTokenCount); err == nil {
|
||||
result["transaction_count"] = blockscoutTxCount
|
||||
result["token_count"] = blockscoutTokenCount
|
||||
}
|
||||
|
||||
var liveTxCount int64
|
||||
if err := s.db.QueryRow(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM transactions
|
||||
WHERE from_address_hash = decode($1, 'hex') OR to_address_hash = decode($1, 'hex')
|
||||
`, normalizedAddress).Scan(&liveTxCount); err == nil && liveTxCount > 0 {
|
||||
result["transaction_count"] = liveTxCount
|
||||
}
|
||||
|
||||
var liveTokenCount int64
|
||||
if err := s.db.QueryRow(ctx, `
|
||||
SELECT COUNT(DISTINCT token_contract_address_hash)
|
||||
FROM token_transfers
|
||||
WHERE from_address_hash = decode($1, 'hex') OR to_address_hash = decode($1, 'hex')
|
||||
`, normalizedAddress).Scan(&liveTokenCount); err == nil && liveTokenCount > 0 {
|
||||
result["token_count"] = liveTokenCount
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT concat('0x', encode(hash, 'hex'))
|
||||
FROM transactions
|
||||
WHERE from_address_hash = decode($1, 'hex') OR to_address_hash = decode($1, 'hex')
|
||||
ORDER BY block_number DESC, index DESC
|
||||
LIMIT 5
|
||||
`, normalizedAddress)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var hash string
|
||||
if scanErr := rows.Scan(&hash); scanErr == nil {
|
||||
recentHashes = append(recentHashes, hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(recentHashes) > 0 {
|
||||
result["recent_transactions"] = recentHashes
|
||||
}
|
||||
}
|
||||
|
||||
if len(result) == 1 {
|
||||
return nil, fmt.Errorf("address not found")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Server) queryAIBlock(ctx context.Context, blockNumber int64) (map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
|
||||
defer cancel()
|
||||
|
||||
query := `
|
||||
SELECT number, hash, parent_hash, transaction_count, gas_used, gas_limit, timestamp_iso
|
||||
FROM blocks
|
||||
WHERE chain_id = $1 AND number = $2
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
var number int64
|
||||
var hash, parentHash string
|
||||
var transactionCount int64
|
||||
var gasUsed, gasLimit int64
|
||||
var timestampISO *string
|
||||
|
||||
err := s.db.QueryRow(ctx, query, s.chainID, blockNumber).Scan(&number, &hash, &parentHash, &transactionCount, &gasUsed, &gasLimit, ×tampISO)
|
||||
if err != nil {
|
||||
blockscoutQuery := `
|
||||
SELECT
|
||||
number,
|
||||
concat('0x', encode(hash, 'hex')) AS hash,
|
||||
concat('0x', encode(parent_hash, 'hex')) AS parent_hash,
|
||||
(SELECT COUNT(*) FROM transactions WHERE block_number = b.number) AS transaction_count,
|
||||
gas_used,
|
||||
gas_limit,
|
||||
TO_CHAR(timestamp AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS timestamp_iso
|
||||
FROM blocks b
|
||||
WHERE number = $1
|
||||
LIMIT 1
|
||||
`
|
||||
if fallbackErr := s.db.QueryRow(ctx, blockscoutQuery, blockNumber).Scan(&number, &hash, &parentHash, &transactionCount, &gasUsed, &gasLimit, ×tampISO); fallbackErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
block := map[string]any{
|
||||
"number": number,
|
||||
"hash": hash,
|
||||
"parent_hash": parentHash,
|
||||
"transaction_count": transactionCount,
|
||||
"gas_used": gasUsed,
|
||||
"gas_limit": gasLimit,
|
||||
}
|
||||
if timestampISO != nil {
|
||||
block["timestamp_iso"] = *timestampISO
|
||||
}
|
||||
return block, nil
|
||||
}
|
||||
|
||||
func extractBlockReference(query string) int64 {
|
||||
match := blockRefPattern.FindStringSubmatch(query)
|
||||
if len(match) != 2 {
|
||||
return 0
|
||||
}
|
||||
var value int64
|
||||
fmt.Sscan(match[1], &value)
|
||||
return value
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func loadAIDocSnippets(query string) ([]AIDocSnippet, string, string) {
|
||||
root := findAIWorkspaceRoot()
|
||||
if root == "" {
|
||||
return nil, "", "workspace docs root unavailable for ai doc retrieval"
|
||||
}
|
||||
|
||||
relativePaths := []string{
|
||||
"docs/11-references/ADDRESS_MATRIX_AND_STATUS.md",
|
||||
"docs/11-references/LIQUIDITY_POOLS_MASTER_MAP.md",
|
||||
"docs/11-references/DEPLOYED_TOKENS_BRIDGES_LPS_AND_ROUTING_STATUS.md",
|
||||
"docs/11-references/EXPLORER_TOKEN_LIST_CROSSCHECK.md",
|
||||
"explorer-monorepo/docs/EXPLORER_API_ACCESS.md",
|
||||
}
|
||||
|
||||
terms := buildDocSearchTerms(query)
|
||||
if len(terms) == 0 {
|
||||
terms = []string{"chain 138", "bridge", "liquidity"}
|
||||
}
|
||||
|
||||
snippets := []AIDocSnippet{}
|
||||
for _, rel := range relativePaths {
|
||||
fullPath := filepath.Join(root, rel)
|
||||
fileSnippets := scanDocForTerms(fullPath, rel, terms)
|
||||
snippets = append(snippets, fileSnippets...)
|
||||
if len(snippets) >= maxExplorerAIDocSnippets {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(snippets) == 0 {
|
||||
return nil, root, "no matching workspace docs found for ai context"
|
||||
}
|
||||
if len(snippets) > maxExplorerAIDocSnippets {
|
||||
snippets = snippets[:maxExplorerAIDocSnippets]
|
||||
}
|
||||
return snippets, root, ""
|
||||
}
|
||||
|
||||
func findAIWorkspaceRoot() string {
|
||||
candidates := []string{}
|
||||
if envRoot := strings.TrimSpace(os.Getenv("EXPLORER_AI_WORKSPACE_ROOT")); envRoot != "" {
|
||||
candidates = append(candidates, envRoot)
|
||||
}
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
candidates = append(candidates, cwd)
|
||||
dir := cwd
|
||||
for i := 0; i < 4; i++ {
|
||||
dir = filepath.Dir(dir)
|
||||
candidates = append(candidates, dir)
|
||||
}
|
||||
}
|
||||
candidates = append(candidates, "/opt/explorer-monorepo", "/home/intlc/projects/proxmox")
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if fileExists(filepath.Join(candidate, "docs")) && (fileExists(filepath.Join(candidate, "explorer-monorepo")) || fileExists(filepath.Join(candidate, "smom-dbis-138")) || fileExists(filepath.Join(candidate, "config"))) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func scanDocForTerms(fullPath, relativePath string, terms []string) []AIDocSnippet {
|
||||
file, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
normalizedTerms := make([]string, 0, len(terms))
|
||||
for _, term := range terms {
|
||||
term = strings.ToLower(strings.TrimSpace(term))
|
||||
if len(term) >= 3 {
|
||||
normalizedTerms = append(normalizedTerms, term)
|
||||
}
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
lineNumber := 0
|
||||
snippets := []AIDocSnippet{}
|
||||
for scanner.Scan() {
|
||||
lineNumber++
|
||||
line := scanner.Text()
|
||||
lower := strings.ToLower(line)
|
||||
for _, term := range normalizedTerms {
|
||||
if strings.Contains(lower, term) {
|
||||
snippets = append(snippets, AIDocSnippet{
|
||||
Path: relativePath,
|
||||
Line: lineNumber,
|
||||
Snippet: clipString(strings.TrimSpace(line), 280),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(snippets) >= 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return snippets
|
||||
}
|
||||
|
||||
func buildDocSearchTerms(query string) []string {
|
||||
words := strings.Fields(strings.ToLower(query))
|
||||
stopWords := map[string]bool{
|
||||
"what": true, "when": true, "where": true, "which": true, "with": true, "from": true,
|
||||
"that": true, "this": true, "have": true, "about": true, "into": true, "show": true,
|
||||
"live": true, "help": true, "explain": true, "tell": true,
|
||||
}
|
||||
terms := []string{}
|
||||
for _, word := range words {
|
||||
word = strings.Trim(word, ".,:;!?()[]{}\"'")
|
||||
if len(word) < 4 || stopWords[word] {
|
||||
continue
|
||||
}
|
||||
terms = append(terms, word)
|
||||
}
|
||||
for _, match := range addressPattern.FindAllString(query, -1) {
|
||||
terms = append(terms, strings.ToLower(match))
|
||||
}
|
||||
for _, symbol := range []string{"cUSDT", "cUSDC", "cXAUC", "cEURT", "USDT", "USDC", "WETH", "WETH10", "Mainnet", "bridge", "liquidity", "pool"} {
|
||||
if strings.Contains(strings.ToLower(query), strings.ToLower(symbol)) {
|
||||
terms = append(terms, strings.ToLower(symbol))
|
||||
}
|
||||
}
|
||||
return uniqueStrings(terms)
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func firstRegexMatch(pattern *regexp.Regexp, value string) string {
|
||||
match := pattern.FindString(value)
|
||||
return strings.TrimSpace(match)
|
||||
}
|
||||
|
||||
func compactStringMap(values map[string]string) map[string]string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
for key, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
out[key] = trimmed
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func compactAnyMap(values map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for key, value := range values {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(typed) == "" {
|
||||
continue
|
||||
}
|
||||
case []string:
|
||||
if len(typed) == 0 {
|
||||
continue
|
||||
}
|
||||
case []any:
|
||||
if len(typed) == 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case fmt.Stringer:
|
||||
return typed.String()
|
||||
default:
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func stringSliceValue(value any) []string {
|
||||
switch typed := value.(type) {
|
||||
case []string:
|
||||
return typed
|
||||
case []any:
|
||||
out := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
out = append(out, stringValue(item))
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" || seen[trimmed] {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = true
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func clipString(value string, limit int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if limit <= 0 || len(value) <= limit {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(value[:limit]) + "..."
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info != nil
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Server) queryAIRoutes(ctx context.Context, query string) ([]map[string]any, string) {
|
||||
baseURL := strings.TrimSpace(firstNonEmptyEnv(
|
||||
"TOKEN_AGGREGATION_API_BASE",
|
||||
"TOKEN_AGGREGATION_URL",
|
||||
"TOKEN_AGGREGATION_BASE_URL",
|
||||
))
|
||||
if baseURL == "" {
|
||||
return nil, "token aggregation api base url is not configured for ai route retrieval"
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(baseURL, "/")+"/api/v1/routes/ingestion?fromChainId=138", nil)
|
||||
if err != nil {
|
||||
return nil, "unable to build token aggregation ai request"
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 6 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "token aggregation live routes unavailable: " + err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Sprintf("token aggregation live routes returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Routes []map[string]any `json:"routes"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return nil, "unable to decode token aggregation live routes"
|
||||
}
|
||||
if len(payload.Routes) == 0 {
|
||||
return nil, "token aggregation returned no live routes"
|
||||
}
|
||||
|
||||
matches := filterAIRouteMatches(payload.Routes, query)
|
||||
return matches, ""
|
||||
}
|
||||
|
||||
func filterAIRouteMatches(routes []map[string]any, query string) []map[string]any {
|
||||
query = strings.ToLower(strings.TrimSpace(query))
|
||||
matches := make([]map[string]any, 0, 6)
|
||||
for _, route := range routes {
|
||||
if query != "" && !routeMatchesQuery(route, query) {
|
||||
continue
|
||||
}
|
||||
trimmed := map[string]any{
|
||||
"routeId": route["routeId"],
|
||||
"status": route["status"],
|
||||
"routeType": route["routeType"],
|
||||
"fromChainId": route["fromChainId"],
|
||||
"toChainId": route["toChainId"],
|
||||
"tokenInSymbol": route["tokenInSymbol"],
|
||||
"tokenOutSymbol": route["tokenOutSymbol"],
|
||||
"assetSymbol": route["assetSymbol"],
|
||||
"label": route["label"],
|
||||
"aggregatorFamilies": route["aggregatorFamilies"],
|
||||
"hopCount": route["hopCount"],
|
||||
"bridgeType": route["bridgeType"],
|
||||
"tags": route["tags"],
|
||||
}
|
||||
matches = append(matches, compactAnyMap(trimmed))
|
||||
if len(matches) >= 6 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
for _, route := range routes {
|
||||
trimmed := map[string]any{
|
||||
"routeId": route["routeId"],
|
||||
"status": route["status"],
|
||||
"routeType": route["routeType"],
|
||||
"fromChainId": route["fromChainId"],
|
||||
"toChainId": route["toChainId"],
|
||||
"tokenInSymbol": route["tokenInSymbol"],
|
||||
"tokenOutSymbol": route["tokenOutSymbol"],
|
||||
"assetSymbol": route["assetSymbol"],
|
||||
"label": route["label"],
|
||||
"aggregatorFamilies": route["aggregatorFamilies"],
|
||||
}
|
||||
matches = append(matches, compactAnyMap(trimmed))
|
||||
if len(matches) >= 4 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
func normalizeHexString(value string) string {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(value))
|
||||
return strings.TrimPrefix(trimmed, "0x")
|
||||
}
|
||||
|
||||
func routeMatchesQuery(route map[string]any, query string) bool {
|
||||
fields := []string{
|
||||
stringValue(route["routeId"]),
|
||||
stringValue(route["routeType"]),
|
||||
stringValue(route["tokenInSymbol"]),
|
||||
stringValue(route["tokenOutSymbol"]),
|
||||
stringValue(route["assetSymbol"]),
|
||||
stringValue(route["label"]),
|
||||
}
|
||||
for _, field := range fields {
|
||||
if strings.Contains(strings.ToLower(field), query) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, value := range stringSliceValue(route["aggregatorFamilies"]) {
|
||||
if strings.Contains(strings.ToLower(value), query) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, value := range stringSliceValue(route["tags"]) {
|
||||
if strings.Contains(strings.ToLower(value), query) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, symbol := range []string{"cusdt", "cusdc", "cxauc", "ceurt", "usdt", "usdc", "weth"} {
|
||||
if strings.Contains(query, symbol) {
|
||||
if strings.Contains(strings.ToLower(strings.Join(fields, " ")), symbol) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type xAIChatCompletionsRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []xAIChatMessageReq `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type xAIChatMessageReq struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type xAIChatCompletionsResponse struct {
|
||||
Model string `json:"model"`
|
||||
Choices []xAIChoice `json:"choices"`
|
||||
OutputText string `json:"output_text,omitempty"`
|
||||
Output []openAIOutputItem `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
type xAIChoice struct {
|
||||
Message xAIChoiceMessage `json:"message"`
|
||||
}
|
||||
|
||||
type xAIChoiceMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type openAIOutputItem struct {
|
||||
Type string `json:"type"`
|
||||
Content []openAIOutputContent `json:"content"`
|
||||
}
|
||||
|
||||
type openAIOutputContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func normalizeAIMessages(messages []AIChatMessage) []AIChatMessage {
|
||||
normalized := make([]AIChatMessage, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
role := strings.ToLower(strings.TrimSpace(message.Role))
|
||||
if role != "assistant" && role != "user" && role != "system" {
|
||||
continue
|
||||
}
|
||||
content := clipString(strings.TrimSpace(message.Content), maxExplorerAIMessageChars)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
normalized = append(normalized, AIChatMessage{
|
||||
Role: role,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
if len(normalized) > maxExplorerAIMessages {
|
||||
normalized = normalized[len(normalized)-maxExplorerAIMessages:]
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func latestUserMessage(messages []AIChatMessage) string {
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if messages[i].Role == "user" {
|
||||
return messages[i].Content
|
||||
}
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return ""
|
||||
}
|
||||
return messages[len(messages)-1].Content
|
||||
}
|
||||
|
||||
func (s *Server) callXAIChatCompletions(ctx context.Context, messages []AIChatMessage, contextEnvelope AIContextEnvelope) (string, string, error) {
|
||||
apiKey := strings.TrimSpace(os.Getenv("XAI_API_KEY"))
|
||||
if apiKey == "" {
|
||||
return "", "", fmt.Errorf("XAI_API_KEY is not configured")
|
||||
}
|
||||
|
||||
model := explorerAIModel()
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("XAI_BASE_URL")), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.x.ai/v1"
|
||||
}
|
||||
|
||||
contextJSON, _ := json.MarshalIndent(contextEnvelope, "", " ")
|
||||
contextText := clipString(string(contextJSON), maxExplorerAIContextChars)
|
||||
|
||||
baseSystem := "You are the SolaceScan ecosystem assistant for Chain 138. Answer using the supplied indexed explorer data, route inventory, and workspace documentation. Be concise, operationally useful, and explicit about uncertainty. Never claim a route, deployment, or production status is live unless the provided context says it is live. If data is missing, say exactly what is missing."
|
||||
if !explorerAIOperatorToolsEnabled() {
|
||||
baseSystem += " Never instruct users to paste private keys or seed phrases. Do not direct users to run privileged mint, liquidity, or bridge execution from the public explorer UI. Operator changes belong on LAN-gated workflows and authenticated Track 4 APIs; PMM/MCP-style execution tools are disabled on this deployment unless EXPLORER_AI_OPERATOR_TOOLS_ENABLED=1."
|
||||
}
|
||||
|
||||
input := []xAIChatMessageReq{
|
||||
{
|
||||
Role: "system",
|
||||
Content: baseSystem,
|
||||
},
|
||||
{
|
||||
Role: "system",
|
||||
Content: "Retrieved ecosystem context:\n" + contextText,
|
||||
},
|
||||
}
|
||||
|
||||
for _, message := range messages {
|
||||
input = append(input, xAIChatMessageReq{
|
||||
Role: message.Role,
|
||||
Content: message.Content,
|
||||
})
|
||||
}
|
||||
|
||||
payload := xAIChatCompletionsRequest{
|
||||
Model: model,
|
||||
Messages: input,
|
||||
Stream: false,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", model, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", model, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 45 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "", model, &AIUpstreamError{
|
||||
StatusCode: http.StatusGatewayTimeout,
|
||||
Code: "upstream_timeout",
|
||||
Message: "explorer ai upstream timed out",
|
||||
Details: "xAI request exceeded the configured timeout",
|
||||
}
|
||||
}
|
||||
return "", model, &AIUpstreamError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Code: "upstream_transport_error",
|
||||
Message: "explorer ai upstream transport failed",
|
||||
Details: err.Error(),
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", model, &AIUpstreamError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Code: "upstream_bad_response",
|
||||
Message: "explorer ai upstream body could not be read",
|
||||
Details: err.Error(),
|
||||
}
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", model, parseXAIError(resp.StatusCode, responseBody)
|
||||
}
|
||||
|
||||
var response xAIChatCompletionsResponse
|
||||
if err := json.Unmarshal(responseBody, &response); err != nil {
|
||||
return "", model, &AIUpstreamError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Code: "upstream_bad_response",
|
||||
Message: "explorer ai upstream returned invalid JSON",
|
||||
Details: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
reply := ""
|
||||
if len(response.Choices) > 0 {
|
||||
reply = strings.TrimSpace(response.Choices[0].Message.Content)
|
||||
}
|
||||
if reply == "" {
|
||||
reply = strings.TrimSpace(response.OutputText)
|
||||
}
|
||||
if reply == "" {
|
||||
reply = strings.TrimSpace(extractOutputText(response.Output))
|
||||
}
|
||||
if reply == "" {
|
||||
return "", model, &AIUpstreamError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Code: "upstream_bad_response",
|
||||
Message: "explorer ai upstream returned no output text",
|
||||
Details: "xAI response did not include choices[0].message.content or output text",
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(response.Model) != "" {
|
||||
model = response.Model
|
||||
}
|
||||
return reply, model, nil
|
||||
}
|
||||
|
||||
func parseXAIError(statusCode int, responseBody []byte) error {
|
||||
var parsed struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(responseBody, &parsed)
|
||||
|
||||
details := clipString(strings.TrimSpace(parsed.Error.Message), 280)
|
||||
if details == "" {
|
||||
details = clipString(strings.TrimSpace(string(responseBody)), 280)
|
||||
}
|
||||
|
||||
switch statusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return &AIUpstreamError{
|
||||
StatusCode: statusCode,
|
||||
Code: "upstream_auth_failed",
|
||||
Message: "explorer ai upstream authentication failed",
|
||||
Details: details,
|
||||
}
|
||||
case http.StatusTooManyRequests:
|
||||
return &AIUpstreamError{
|
||||
StatusCode: statusCode,
|
||||
Code: "upstream_quota_exhausted",
|
||||
Message: "explorer ai upstream quota exhausted",
|
||||
Details: details,
|
||||
}
|
||||
case http.StatusRequestTimeout, http.StatusGatewayTimeout:
|
||||
return &AIUpstreamError{
|
||||
StatusCode: statusCode,
|
||||
Code: "upstream_timeout",
|
||||
Message: "explorer ai upstream timed out",
|
||||
Details: details,
|
||||
}
|
||||
default:
|
||||
return &AIUpstreamError{
|
||||
StatusCode: statusCode,
|
||||
Code: "upstream_error",
|
||||
Message: "explorer ai upstream request failed",
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractOutputText(items []openAIOutputItem) string {
|
||||
parts := []string{}
|
||||
for _, item := range items {
|
||||
for _, content := range item.Content {
|
||||
if strings.TrimSpace(content.Text) != "" {
|
||||
parts = append(parts, strings.TrimSpace(content.Text))
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
86
docs/TESTING.md
Normal file
86
docs/TESTING.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Testing
|
||||
|
||||
The explorer has four test tiers. Run them in order of fidelity when
|
||||
debugging a regression.
|
||||
|
||||
## 1. Unit / package tests
|
||||
|
||||
Fast. Run on every PR.
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend && go test ./...
|
||||
|
||||
# Frontend
|
||||
cd frontend && npm test # lint + type-check
|
||||
cd frontend && npm run test:unit # vitest
|
||||
```
|
||||
|
||||
## 2. Static analysis
|
||||
|
||||
Blocking on CI since PR #5 (`chore(ci): align Go to 1.23.x, add
|
||||
staticcheck/govulncheck/gitleaks gates`).
|
||||
|
||||
```bash
|
||||
cd backend && staticcheck ./...
|
||||
cd backend && govulncheck ./...
|
||||
git diff master... | gitleaks protect --staged --config ../.gitleaks.toml
|
||||
```
|
||||
|
||||
## 3. Production-targeting Playwright
|
||||
|
||||
Runs against `https://explorer.d-bis.org` (or the URL in `EXPLORER_URL`)
|
||||
and only checks public routes. Useful as a production canary; wired
|
||||
into the `test-e2e` Make target.
|
||||
|
||||
```bash
|
||||
EXPLORER_URL=https://explorer.d-bis.org make test-e2e
|
||||
```
|
||||
|
||||
## 4. Full-stack Playwright (`make e2e-full`)
|
||||
|
||||
Spins up the entire stack locally — `postgres`, `elasticsearch`,
|
||||
`redis` via docker-compose, plus a local build of `backend/api/rest`
|
||||
and `frontend` — then runs the full-stack Playwright spec against it.
|
||||
|
||||
```bash
|
||||
make e2e-full
|
||||
```
|
||||
|
||||
What it does, in order:
|
||||
|
||||
1. `docker compose -p explorer-e2e up -d postgres elasticsearch redis`
|
||||
2. Wait for Postgres readiness.
|
||||
3. Run `go run database/migrations/migrate.go` to apply schema +
|
||||
seeds (including `0016_jwt_revocations` from PR #8).
|
||||
4. `go run ./backend/api/rest` on port `8080`.
|
||||
5. `npm ci && npm run build && npm run start` on port `3000`.
|
||||
6. `npx playwright test scripts/e2e-full-stack.spec.ts`.
|
||||
7. Tear everything down (unless `E2E_KEEP_STACK=1`).
|
||||
|
||||
Screenshots of every route are written to
|
||||
`test-results/screenshots/<route>.png`.
|
||||
|
||||
### Env vars
|
||||
|
||||
| Var | Default | Purpose |
|
||||
|-----|---------|---------|
|
||||
| `EXPLORER_URL` | `http://localhost:3000` | Frontend base URL for the spec |
|
||||
| `EXPLORER_API_URL` | `http://localhost:8080` | Backend base URL |
|
||||
| `JWT_SECRET` | generated per-run | Required by backend fail-fast check (PR #3) |
|
||||
| `CSP_HEADER` | dev-safe default | Same |
|
||||
| `E2E_KEEP_STACK` | `0` | If `1`, leave the stack up after the run |
|
||||
| `E2E_SKIP_DOCKER` | `0` | If `1`, assume docker services already running |
|
||||
| `E2E_SCREENSHOT_DIR` | `test-results/screenshots` | Where to write PNGs |
|
||||
|
||||
### CI integration
|
||||
|
||||
`.github/workflows/e2e-full.yml` runs `make e2e-full` on:
|
||||
|
||||
* **Manual** trigger (`workflow_dispatch`).
|
||||
* **PRs labelled `run-e2e-full`** — apply the label when a change
|
||||
warrants full-stack validation (migrations, auth, routing changes).
|
||||
* **Nightly** at 04:00 UTC.
|
||||
|
||||
Screenshots and the Playwright HTML report are uploaded as build
|
||||
artefacts.
|
||||
@@ -7,7 +7,7 @@ if (process.env.NO_COLOR !== undefined) {
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './scripts',
|
||||
testMatch: 'e2e-explorer-frontend.spec.ts',
|
||||
testMatch: /e2e-.*\.spec\.ts$/,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
79
scripts/e2e-full-stack.spec.ts
Normal file
79
scripts/e2e-full-stack.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
// e2e-full-stack.spec.ts
|
||||
//
|
||||
// Playwright spec that exercises the golden-path behaviours of the
|
||||
// explorer against a *locally booted* backend + frontend, rather than
|
||||
// against the production deploy that `e2e-explorer-frontend.spec.ts`
|
||||
// targets. `make e2e-full` stands up the stack, points this spec at
|
||||
// it via EXPLORER_URL / EXPLORER_API_URL, and tears it down afterwards.
|
||||
//
|
||||
// The spec intentionally sticks to Track-1 (public, no auth) routes so
|
||||
// it can run without provisioning wallet credentials in CI. Track 2-4
|
||||
// behaviours are covered by the Go and unit-test layers.
|
||||
|
||||
const EXPLORER_URL = process.env.EXPLORER_URL || 'http://localhost:3000'
|
||||
const EXPLORER_API_URL = process.env.EXPLORER_API_URL || 'http://localhost:8080'
|
||||
const SCREENSHOT_DIR = process.env.E2E_SCREENSHOT_DIR || 'test-results/screenshots'
|
||||
|
||||
mkdirSync(SCREENSHOT_DIR, { recursive: true })
|
||||
|
||||
async function snapshot(page: Page, name: string) {
|
||||
const file = path.join(SCREENSHOT_DIR, `${name}.png`)
|
||||
await page.screenshot({ path: file, fullPage: true })
|
||||
}
|
||||
|
||||
async function expectHeading(page: Page, name: RegExp) {
|
||||
await expect(page.getByRole('heading', { name })).toBeVisible({ timeout: 15000 })
|
||||
}
|
||||
|
||||
test.describe('Explorer full-stack smoke', () => {
|
||||
test('backend /healthz responds 200', async ({ request }) => {
|
||||
const response = await request.get(`${EXPLORER_API_URL}/healthz`)
|
||||
expect(response.status()).toBeLessThan(500)
|
||||
})
|
||||
|
||||
for (const route of [
|
||||
{ path: '/', heading: /SolaceScan/i, name: 'home' },
|
||||
{ path: '/blocks', heading: /^Blocks$/i, name: 'blocks' },
|
||||
{ path: '/transactions', heading: /^Transactions$/i, name: 'transactions' },
|
||||
{ path: '/addresses', heading: /^Addresses$/i, name: 'addresses' },
|
||||
{ path: '/tokens', heading: /^Tokens$/i, name: 'tokens' },
|
||||
{ path: '/pools', heading: /^Pools$/i, name: 'pools' },
|
||||
{ path: '/search', heading: /^Search$/i, name: 'search' },
|
||||
{ path: '/wallet', heading: /Wallet & MetaMask/i, name: 'wallet' },
|
||||
{ path: '/routes', heading: /Route/i, name: 'routes' },
|
||||
]) {
|
||||
test(`frontend route ${route.path} renders`, async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}${route.path}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30000,
|
||||
})
|
||||
await expectHeading(page, route.heading)
|
||||
await snapshot(page, route.name)
|
||||
})
|
||||
}
|
||||
|
||||
test('access products endpoint is reachable', async ({ request }) => {
|
||||
// Covers the YAML-backed catalogue wired up in PR #7. The endpoint
|
||||
// is public (lists available RPC products) so no auth is needed.
|
||||
const response = await request.get(`${EXPLORER_API_URL}/api/v1/access/products`)
|
||||
expect(response.status()).toBe(200)
|
||||
const body = await response.json()
|
||||
expect(Array.isArray(body.products)).toBe(true)
|
||||
expect(body.products.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
test('auth nonce endpoint issues a nonce', async ({ request }) => {
|
||||
// Covers wallet auth kickoff: /api/v1/auth/nonce must issue a
|
||||
// fresh nonce even without credentials. This is Track-1-safe.
|
||||
const response = await request.post(`${EXPLORER_API_URL}/api/v1/auth/nonce`, {
|
||||
data: { address: '0x4A666F96fC8764181194447A7dFdb7d471b301C8' },
|
||||
})
|
||||
expect(response.status()).toBe(200)
|
||||
const body = await response.json()
|
||||
expect(typeof body.nonce === 'string' && body.nonce.length > 0).toBe(true)
|
||||
})
|
||||
})
|
||||
123
scripts/e2e-full.sh
Executable file
123
scripts/e2e-full.sh
Executable file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/e2e-full.sh
|
||||
#
|
||||
# Boots the full explorer stack (postgres, elasticsearch, redis, backend
|
||||
# API, frontend), waits for readiness, runs the Playwright full-stack
|
||||
# smoke spec against it, and tears everything down. Used by the
|
||||
# `make e2e-full` target and by the e2e-full CI workflow.
|
||||
#
|
||||
# Env vars:
|
||||
# E2E_KEEP_STACK=1 # don't tear down on exit (for debugging)
|
||||
# E2E_SKIP_DOCKER=1 # assume backend + deps already running
|
||||
# EXPLORER_URL # defaults to http://localhost:3000
|
||||
# EXPLORER_API_URL # defaults to http://localhost:8080
|
||||
# E2E_SCREENSHOT_DIR # defaults to test-results/screenshots
|
||||
# JWT_SECRET # required; generated ephemerally if unset
|
||||
# CSP_HEADER # required; a dev-safe default is injected
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
COMPOSE="deployment/docker-compose.yml"
|
||||
COMPOSE_PROJECT="${COMPOSE_PROJECT:-explorer-e2e}"
|
||||
|
||||
export EXPLORER_URL="${EXPLORER_URL:-http://localhost:3000}"
|
||||
export EXPLORER_API_URL="${EXPLORER_API_URL:-http://localhost:8080}"
|
||||
export E2E_SCREENSHOT_DIR="${E2E_SCREENSHOT_DIR:-$ROOT/test-results/screenshots}"
|
||||
mkdir -p "$E2E_SCREENSHOT_DIR"
|
||||
|
||||
# Generate ephemeral JWT secret if the caller didn't set one. Real
|
||||
# deployments use fail-fast validation (see PR #3); for a local run we
|
||||
# want a fresh value each invocation so stale tokens don't bleed across
|
||||
# runs.
|
||||
export JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}"
|
||||
export CSP_HEADER="${CSP_HEADER:-default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' http://localhost:8080 ws://localhost:8080}"
|
||||
|
||||
log() { printf '[e2e-full] %s\n' "$*"; }
|
||||
|
||||
teardown() {
|
||||
local ec=$?
|
||||
if [[ "${E2E_KEEP_STACK:-0}" == "1" ]]; then
|
||||
log "E2E_KEEP_STACK=1; leaving stack running."
|
||||
return $ec
|
||||
fi
|
||||
log "tearing down stack"
|
||||
if [[ "${E2E_SKIP_DOCKER:-0}" != "1" ]]; then
|
||||
docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "${BACKEND_PID:-}" ]]; then kill "$BACKEND_PID" 2>/dev/null || true; fi
|
||||
if [[ -n "${FRONTEND_PID:-}" ]]; then kill "$FRONTEND_PID" 2>/dev/null || true; fi
|
||||
return $ec
|
||||
}
|
||||
trap teardown EXIT
|
||||
|
||||
wait_for() {
|
||||
local url="$1" label="$2" retries="${3:-60}"
|
||||
log "waiting for $label at $url"
|
||||
for ((i=0; i<retries; i++)); do
|
||||
if curl -fsS "$url" >/dev/null 2>&1; then
|
||||
log " $label ready"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
log " $label never became ready"
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ "${E2E_SKIP_DOCKER:-0}" != "1" ]]; then
|
||||
log "starting postgres, elasticsearch, redis via docker compose"
|
||||
docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE" up -d postgres elasticsearch redis
|
||||
|
||||
log "waiting for postgres"
|
||||
for ((i=0; i<60; i++)); do
|
||||
if docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE" exec -T postgres pg_isready -U explorer >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
fi
|
||||
|
||||
export DB_HOST="${DB_HOST:-localhost}"
|
||||
export DB_PORT="${DB_PORT:-5432}"
|
||||
export DB_USER="${DB_USER:-explorer}"
|
||||
export DB_PASSWORD="${DB_PASSWORD:-changeme}"
|
||||
export DB_NAME="${DB_NAME:-explorer}"
|
||||
export REDIS_HOST="${REDIS_HOST:-localhost}"
|
||||
export REDIS_PORT="${REDIS_PORT:-6379}"
|
||||
export ELASTICSEARCH_URL="${ELASTICSEARCH_URL:-http://localhost:9200}"
|
||||
|
||||
log "running migrations"
|
||||
(cd backend && go run database/migrations/migrate.go) || {
|
||||
log "migrations failed; continuing so tests can report the real backend state"
|
||||
}
|
||||
|
||||
log "starting backend API on :8080"
|
||||
(cd backend/api/rest && go run . >/tmp/e2e-backend.log 2>&1) &
|
||||
BACKEND_PID=$!
|
||||
|
||||
wait_for "$EXPLORER_API_URL/healthz" backend 120 || {
|
||||
log "backend log tail:"; tail -n 60 /tmp/e2e-backend.log || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "building frontend"
|
||||
(cd frontend && npm ci --no-audit --no-fund --loglevel=error && npm run build)
|
||||
|
||||
log "starting frontend on :3000"
|
||||
(cd frontend && PORT=3000 HOST=127.0.0.1 NEXT_PUBLIC_API_URL="$EXPLORER_API_URL" npm run start >/tmp/e2e-frontend.log 2>&1) &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
wait_for "$EXPLORER_URL" frontend 60 || {
|
||||
log "frontend log tail:"; tail -n 60 /tmp/e2e-frontend.log || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "running Playwright full-stack smoke"
|
||||
npx playwright install --with-deps chromium >/dev/null
|
||||
EXPLORER_URL="$EXPLORER_URL" EXPLORER_API_URL="$EXPLORER_API_URL" \
|
||||
npx playwright test scripts/e2e-full-stack.spec.ts --reporter=list
|
||||
|
||||
log "done; screenshots in $E2E_SCREENSHOT_DIR"
|
||||
Reference in New Issue
Block a user