Files
universe/e2e/20-exchange-deep.spec.ts
zeekayandHanzo Dev d9b5f7a9a3 test(e2e): assert the chain is MOVING, and open every page of every product
The suite was green while mainnet had not produced a block in 59 hours.

Nothing in it asked whether the chain was alive. `00-health` has nine tests
that all reduce to `eth_blockNumber > 0` — "node reports healthy", "C-Chain is
bootstrapped", "all primary chains bootstrapped" — and a frozen chain answers
every one of them. Readiness agreed: 15 pods at 5/5 Ready, every public API
200, on all three networks, one of which was stopped.

  mainnet 96369  tip 1,098,191  age 58.9h   testnet 96368  tip 1,354  age 3.6m
  devnet  96367  tip       528  age  3.3m

e2e/00-liveness.spec.ts gates on the TIP. A fixed "block every N seconds" is
wrong for demand-driven chains, so the gate CALIBRATES ITSELF: the tip may be
no older than 4x the chain's own median gap over its last 8 blocks, floored at
600s. Measured cadences are 7s on mainnet and ~300s on testnet/devnet (the
heartbeat CronJob) — no constant spans both. Median, not mean: one 27,375s gap
IS the freeze, and a mean would let a stall widen its own budget.

Four orthogonal tests: fresh, monotonic (the tip never goes backwards and a
served block keeps its hash — the reorg shape devnet had at height 512),
consistent (net_version vs NETWORKS.yaml), and honest — a full validator set
must imply a fresh tip, which asserts the readiness lie head-on. Mainnet is RED
on three of them. That is the correct result; do not widen the budget.

Coverage was a function of which spec somebody happened to write: Safe's 31
routes, MPC's 15 and the whole admin surface had ZERO tests between them while
api.lux.financial, whose API is dead, carried 293. So routes become DATA —
e2e/routes.ts, 156 declared pages across 11 surfaces, iterated by 60-surfaces
(anonymous) and 61-console (signed in). A new page is one line there.

HTTP status is not evidence: SEVEN of the nine origins answer 200 for a path
that does not exist (measured with a /zzz-bogus control each; only Safe and the
explorer 404 honestly, and those two get a live control test every run). Every
route is therefore opened in a browser and must have PAINTED — real text into a
non-empty root, AND not the app's error boundary. Two assertions, because a
crash screen sails through a length check: exchange /pool is 200 with 553
characters that all say "Something went wrong". One reload before failing,
which is exactly the line between a page with no retry and a broken page.

First run found, each with a screenshot:
  exchange /positions /pool /pool/v2 /bridge /teleport  crash screen at 200
  exchange /vote                                        white screen, 0 chars
  explorer /tokens /stats /advanced-filter              crash screen at 200
  console /dashboard/{safes,mpc,bridge,dex,deploy}      no network, chainId,
    address or height anywhere on screen — five of the nine surfaces the owner
    named, and nothing on them tells an operator which chain they are on

Also fixed, all of them tests that could not fail:
- Peer connectivity was `catch { console.log('firewalled — PASS'); return }`.
  /v1/info is not uniformly firewalled, it is inconsistently deployed (POST:
  testnet 200, mainnet 404, devnet 404). Now asserts mesh depth wherever it is
  served and falls back to the P-Chain validator set, which a single-node
  network fails.
- 02-exchange and 20-exchange-deep skipped 58 cases behind lux.exchange, whose
  TLS handshake fails from this network in curl, python and chromium alike.
  exchange.lux.network is the same deployment at 200 on all 22 routes.
  helpers.exchangeBase() picks the door that answers; the vanity domain being
  down is now one deliberate assertion instead of a silence over 22 pages.
- 13-iam asserted a hand-built /.well-known/jwks that returns 200 text/html —
  the SPA catch-all — so JSON.parse threw. Follows the discovery document now,
  which declares /v1/iam/.well-known/jwks, and checks the content-type.
- 50-login sliced userinfo to 300 chars and then parsed it, so a real sign-in
  failed on "Unterminated string in JSON" AFTER proving IAM accepted the token.
  Parsed in-page now, three claims out, nothing else in the log.

Login proven end to end: [email protected] -> lux.id -> /dashboard, iss=lux.id,
aud=lux-cloud-org-lux, userinfo 200, sign-out present. 3/3.

Read-only throughout. Every screen in 61-console can move value — treasury,
safes, mpc, bridge. It navigates and screenshots. No form submitted, nothing
prepared, nothing signed, nothing broadcast, nothing re-keyed.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-26 20:20:52 -07:00

403 lines
18 KiB
TypeScript

import { test, expect } from '@playwright/test'
import { getUrls, exchangeBase, httpStatus, snap } from './helpers'
const urls = getUrls()
// ---------------------------------------------------------------------------
// Which door — not whether to bother
// ---------------------------------------------------------------------------
// All 36 tests in this file skipped on every run, because the guard probed
// `lux.exchange`, whose TLS handshake fails from this network, and concluded
// the exchange was down. It is not: `exchange.lux.network` is the same
// deployment and serves 200 on all 22 routes probed 2026-07-26. A whole deep
// suite silenced by a hostname.
//
// The guard is now a RESOLVER — it returns the door that answers, and only
// aborts if BOTH are dead, which would be a genuine outage worth stopping for.
// helpers.exchangeBase() holds the choice, so 02-exchange and this file cannot
// disagree about where the exchange lives. Whether the vanity domain works is
// asserted once, on purpose, in 02-exchange.
async function base(request: import('@playwright/test').APIRequestContext): Promise<string> {
return await exchangeBase(request)
}
async function skipIfUnreachable(request: import('@playwright/test').APIRequestContext) {
const door = await base(request)
const status = await httpStatus(request, door, 15_000)
expect(status,
`both ${urls.exchange} and ${urls.exchangeOrigin} are unreachable — the exchange is down`)
.toBeGreaterThan(0)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Navigate to an exchange route, wait for content to settle */
async function navigateTo(page: import('@playwright/test').Page, route: string, waitMs = 3000) {
await page.goto(`${await base(page.context().request)}${route}`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(waitMs)
}
/** Assert page has no "Something went wrong" crash screen */
async function assertNoCrash(page: import('@playwright/test').Page) {
const errorCount = await page.locator('text=/Something went wrong/i').count()
expect(errorCount, 'Page shows "Something went wrong" crash').toBe(0)
}
// =========================================================================
// 1. Core Trading — Swap Interface
// =========================================================================
test.describe('Exchange — Swap Interface', () => {
test('swap page loads without crash', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/swap')
await assertNoCrash(page)
const content = await page.textContent('body')
expect(content?.length).toBeGreaterThan(0)
await snap(page, 'exchange-swap-load')
})
test('swap has token input fields', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/swap', 5000)
await assertNoCrash(page)
// Look for input fields (amount inputs for sell/buy)
const inputs = page.locator('input[type="text"], input[type="number"], input[inputmode="decimal"]')
const count = await inputs.count()
expect(count, 'Expected at least 1 token amount input').toBeGreaterThanOrEqual(1)
await snap(page, 'exchange-swap-inputs')
})
test('swap has Swap/Limit/Buy/Sell tabs', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/swap', 5000)
await assertNoCrash(page)
const body = (await page.textContent('body')) || ''
const bodyLower = body.toLowerCase()
// The exchange should have navigation tabs for different trade modes
const hasSwap = bodyLower.includes('swap')
const hasLimit = bodyLower.includes('limit')
const hasBuy = bodyLower.includes('buy')
expect(hasSwap, 'Missing "Swap" tab').toBeTruthy()
expect(hasLimit, 'Missing "Limit" tab').toBeTruthy()
expect(hasBuy, 'Missing "Buy" tab').toBeTruthy()
await snap(page, 'exchange-swap-tabs')
})
})
// =========================================================================
// 2. Liquidity / Positions
// =========================================================================
test.describe('Exchange — Positions & Liquidity', () => {
test('positions page loads', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/positions', 5000)
await assertNoCrash(page)
const body = (await page.textContent('body')) || ''
const bodyLower = body.toLowerCase()
const hasPositions = bodyLower.includes('position') || bodyLower.includes('liquidity')
expect(hasPositions, 'Positions page should mention positions or liquidity').toBeTruthy()
await snap(page, 'exchange-positions')
})
test('positions has v2/v3/v4 filter tabs', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/positions', 5000)
await assertNoCrash(page)
const body = (await page.textContent('body')) || ''
// Check for protocol version filters
const hasV2 = body.includes('v2') || body.includes('V2')
const hasV3 = body.includes('v3') || body.includes('V3')
const hasV4 = body.includes('v4') || body.includes('V4')
// At least v3 and v4 should be present (v2 may be hidden on some configs)
expect(hasV3 || hasV4, 'Expected at least v3 or v4 filter tabs').toBeTruthy()
console.log(` Position filters — v2: ${hasV2}, v3: ${hasV3}, v4: ${hasV4}`)
await snap(page, 'exchange-positions-filters')
})
test('new v4 position page loads', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/positions/create/v4', 5000)
await assertNoCrash(page)
const content = await page.textContent('body')
expect(content?.length).toBeGreaterThan(0)
await snap(page, 'exchange-positions-create-v4')
})
test('new v3 position page loads', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/positions/create/v3', 5000)
await assertNoCrash(page)
const content = await page.textContent('body')
expect(content?.length).toBeGreaterThan(0)
await snap(page, 'exchange-positions-create-v3')
})
test('add liquidity page loads', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/add', 5000)
await assertNoCrash(page)
const content = await page.textContent('body')
expect(content?.length).toBeGreaterThan(0)
await snap(page, 'exchange-add-liquidity-deep')
})
})
// =========================================================================
// 3. Explore & Analytics
// =========================================================================
test.describe('Exchange — Explore', () => {
test('explore tokens page loads with data', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/explore/tokens', 5000)
await assertNoCrash(page)
const body = (await page.textContent('body')) || ''
const bodyLower = body.toLowerCase()
// Tokens page should have a table or list of tokens
const hasTokenContent = bodyLower.includes('token') || bodyLower.includes('price') || bodyLower.includes('volume')
expect(hasTokenContent, 'Explore tokens page should show token data').toBeTruthy()
await snap(page, 'exchange-explore-tokens')
})
test('explore pools page loads with data', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/explore/pools', 5000)
await assertNoCrash(page)
const body = (await page.textContent('body')) || ''
const bodyLower = body.toLowerCase()
const hasPoolContent = bodyLower.includes('pool') || bodyLower.includes('tvl') || bodyLower.includes('liquidity')
expect(hasPoolContent, 'Explore pools page should show pool/TVL data').toBeTruthy()
await snap(page, 'exchange-explore-pools')
})
test('explore has chain selector', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/explore', 5000)
await assertNoCrash(page)
// Look for a chain/network selector element
const chainSelector = page.locator(
'button:has-text("All networks"), button:has-text("Lux"), [class*="chain"], [class*="network"], [data-testid*="chain"]'
).first()
const visible = await chainSelector.isVisible({ timeout: 10_000 }).catch(() => false)
console.log(` Chain selector visible: ${visible}`)
// Even if the specific selector is not found, the page should load
await snap(page, 'exchange-explore-chain-selector')
})
test('explore shows Lux native chains', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/explore', 5000)
await assertNoCrash(page)
// Try to open chain selector
const networkBtn = page.locator(
'button:has-text("All networks"), button:has-text("All chains"), [class*="ChainSelector"], [data-testid*="chain-selector"]'
).first()
const btnVisible = await networkBtn.isVisible({ timeout: 5_000 }).catch(() => false)
if (btnVisible) {
await networkBtn.click()
await page.waitForTimeout(1000)
}
const body = (await page.textContent('body')) || ''
const bodyLower = body.toLowerCase()
// Check for Lux native chain names
const chains = ['lux', 'zoo', 'pars', 'spc', 'hanzo']
const found = chains.filter(c => bodyLower.includes(c))
console.log(` Lux chains found in explore: ${found.join(', ')}`)
// At least Lux should be present
expect(found.length, 'Expected at least Lux chain in explore').toBeGreaterThanOrEqual(1)
await snap(page, 'exchange-explore-lux-chains')
})
test('explore auctions page loads', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/explore/auctions', 5000)
await assertNoCrash(page)
const content = await page.textContent('body')
expect(content?.length).toBeGreaterThan(0)
await snap(page, 'exchange-explore-auctions')
})
test('explore transactions page loads', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/explore/transactions', 5000)
await assertNoCrash(page)
const body = (await page.textContent('body')) || ''
const bodyLower = body.toLowerCase()
const hasTxContent = bodyLower.includes('transaction') || bodyLower.includes('swap') || bodyLower.includes('recent')
expect(hasTxContent, 'Transactions page should show transaction data').toBeTruthy()
await snap(page, 'exchange-explore-transactions')
})
})
// =========================================================================
// 4. Advanced Trading
// =========================================================================
test.describe('Exchange — Advanced Trading', () => {
test('limit orders page loads with form', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/limit', 5000)
await assertNoCrash(page)
const body = (await page.textContent('body')) || ''
const bodyLower = body.toLowerCase()
const hasLimitContent = bodyLower.includes('limit') || bodyLower.includes('price') || bodyLower.includes('sell') || bodyLower.includes('buy')
expect(hasLimitContent, 'Limit page should show limit order form').toBeTruthy()
await snap(page, 'exchange-limit')
})
test('limit has Market/+1%/+5%/+10% presets', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/limit', 5000)
await assertNoCrash(page)
const body = (await page.textContent('body')) || ''
// Look for price presets
const hasMarket = body.includes('Market') || body.includes('market')
const has1Pct = body.includes('+1%') || body.includes('1%')
const has5Pct = body.includes('+5%') || body.includes('5%')
const has10Pct = body.includes('+10%') || body.includes('10%')
console.log(` Limit presets — Market: ${hasMarket}, 1%: ${has1Pct}, 5%: ${has5Pct}, 10%: ${has10Pct}`)
// At least some presets should be visible
const presetCount = [hasMarket, has1Pct, has5Pct, has10Pct].filter(Boolean).length
expect(presetCount, 'Expected at least 1 limit price preset').toBeGreaterThanOrEqual(1)
await snap(page, 'exchange-limit-presets')
})
test('buy crypto page loads', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/buy', 5000)
await assertNoCrash(page)
const body = (await page.textContent('body')) || ''
const bodyLower = body.toLowerCase()
const hasBuyContent = bodyLower.includes('buy') || bodyLower.includes('amount') || bodyLower.includes('fiat') || bodyLower.includes('usd')
expect(hasBuyContent, 'Buy page should show fiat purchase form').toBeTruthy()
await snap(page, 'exchange-buy')
})
test('send page loads without crash', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/send', 5000)
await assertNoCrash(page)
const body = (await page.textContent('body')) || ''
const bodyLower = body.toLowerCase()
const hasSendContent = bodyLower.includes('send') || bodyLower.includes('transfer') || bodyLower.includes('recipient') || bodyLower.includes('address')
expect(hasSendContent, 'Send page should show send form').toBeTruthy()
await snap(page, 'exchange-send')
})
})
// =========================================================================
// 5. Portfolio
// =========================================================================
test.describe('Exchange — Portfolio', () => {
test('portfolio page loads', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/portfolio', 5000)
await assertNoCrash(page)
const content = await page.textContent('body')
expect(content?.length).toBeGreaterThan(0)
await snap(page, 'exchange-portfolio')
})
test('portfolio has network filter with Lux chains', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/portfolio', 5000)
await assertNoCrash(page)
const body = (await page.textContent('body')) || ''
const bodyLower = body.toLowerCase()
// Check for Lux native chain names in network filter
const chains = ['lux', 'zoo', 'pars', 'spc', 'hanzo']
const found = chains.filter(c => bodyLower.includes(c))
console.log(` Portfolio chains found: ${found.join(', ')}`)
// At least Lux should be mentioned
expect(found.length, 'Expected at least Lux chain in portfolio').toBeGreaterThanOrEqual(1)
await snap(page, 'exchange-portfolio-chains')
})
test('portfolio has action buttons', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/portfolio', 5000)
await assertNoCrash(page)
const body = (await page.textContent('body')) || ''
const bodyLower = body.toLowerCase()
// Check for common portfolio action buttons
const actions = ['send', 'receive', 'buy', 'swap']
const found = actions.filter(a => bodyLower.includes(a))
console.log(` Portfolio actions found: ${found.join(', ')}`)
expect(found.length, 'Expected at least 2 portfolio action buttons').toBeGreaterThanOrEqual(2)
await snap(page, 'exchange-portfolio-actions')
})
})
// =========================================================================
// 6. Chain Support
// =========================================================================
test.describe('Exchange — Lux Chain Support', () => {
test('chain selector includes all Lux native chains', async ({ request, page }) => {
await skipIfUnreachable(request)
await navigateTo(page, '/explore', 5000)
await assertNoCrash(page)
// Try to open chain selector
const networkBtn = page.locator(
'button:has-text("All networks"), button:has-text("All chains"), [class*="ChainSelector"], [data-testid*="chain-selector"], [class*="network"]'
).first()
const btnVisible = await networkBtn.isVisible({ timeout: 5_000 }).catch(() => false)
if (btnVisible) {
await networkBtn.click()
await page.waitForTimeout(1000)
}
const body = (await page.textContent('body')) || ''
const bodyLower = body.toLowerCase()
const expectedChains = ['lux', 'zoo', 'pars', 'spc', 'hanzo']
const found: string[] = []
const missing: string[] = []
for (const chain of expectedChains) {
if (bodyLower.includes(chain)) {
found.push(chain)
} else {
missing.push(chain)
}
}
console.log(` Lux chains found: ${found.join(', ')}`)
if (missing.length > 0) console.log(` Lux chains missing: ${missing.join(', ')}`)
// At minimum, Lux chain must be supported
expect(found, 'Lux chain must be in chain selector').toContain('lux')
await snap(page, 'exchange-chain-selector-all')
})
})
// =========================================================================
// 7. No Crashes — visit every major route
// =========================================================================
test.describe('Exchange — No Application Crashes', () => {
const routes = [
'/swap',
'/send',
'/limit',
'/buy',
'/positions',
'/positions/create',
'/positions/create/v3',
'/positions/create/v4',
'/explore',
'/explore/pools',
'/explore/tokens',
'/explore/transactions',
'/explore/auctions',
'/portfolio',
]
for (const route of routes) {
test(`${route} loads without crash`, async ({ request, page }) => {
await skipIfUnreachable(request)
await page.goto(`${await base(page.context().request)}${route}`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
await assertNoCrash(page)
// Verify the page rendered something meaningful (not blank)
const content = await page.textContent('body')
expect(content?.length, `${route} rendered empty body`).toBeGreaterThan(10)
const slug = route.replace(/\//g, '-').replace(/^-/, '') || 'root'
await snap(page, `exchange-no-crash-${slug}`)
})
}
})