Files
universe/e2e/09-defi-flow.spec.ts
zeekayandHanzo Dev e2503dae65 e2e: one environment model, viewport projects, and a login proof that can fail
LUX_ENV=local|devnet|testnet|mainnet is now the only environment knob. e2e/env.ts
resolves every base URL, RPC endpoint, network ID and chain ID from NETWORKS.yaml
at runtime; playwright.config.ts and helpers.ts derive from it instead of keeping
their own copies. Those copies had already drifted — the config resolved the
testnet explorer to test-explore.lux.network, which is NXDOMAIN, while helpers.ts
used the real explore-test.lux.network. The retired two-axis ENV/LUX_NETWORK
model now fails loudly rather than being half-honoured.

Projects are viewports (mobile 390x844, tablet 834x1194, laptop 1440x900), so
"mobile and desktop" is the same three frames every run. Screenshots go to
~/Desktop/lux-e2e/<env>/<surface>-<viewport>.png on pass, and shot-reporter.ts
copies a failing test's dying state next to them — a failure you cannot look at
is a failure you cannot fix.

50-login.spec.ts proves sign-in for real: lux.cloud -> lux.id -> /dashboard, with
the password from LUX_E2E_PASSWORD only. It refuses the cheap answers, because
each one lies here — /dashboard is an SPA that returns 200 and keeps its URL when
anonymous, and /auth/callback renders "Sign-in failed" at 200. The proof is the
token: a JWT in sessionStorage whose iss is the IdP and whose email is the
account typed, which lux.id/v1/iam/oauth/userinfo accepts (401 bare, 401 for a
junk token). That is the leg that breaks under invalid_client. It also asserts
the handoff is a public PKCE client and carries a negative control so the
positive assertions cannot go vacuous. Verified with a wrong password: red.

Measured while building this, not assumed:
  - workers are per-env. Mainnet is CDN-fronted and ran the gate at 4 with
    342/342; testnet hits the raw LB and lost 66 cases to throttling at 4 that
    all passed at 2.
  - `locator('text=/block/i').first()` in two specs resolved to a hidden Radix
    menu trigger, so both tracked the nav's collapse rather than the blocks
    table. Replaced by one shared assertion on the section AND a block link.
  - the token check runs inside the page: page.request.get attaches request
    headers to a thrown error, which printed a live bearer token into the log.
  - 40-lux-financial carried a plaintext password; it now comes from the
    environment.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-26 15:46:04 -07:00

281 lines
12 KiB
TypeScript

import { test, expect } from '@playwright/test'
import {
getUrls, getChainIds,
rpcCall, ethBlockNumber, ethChainId, ethGasPrice,
getCurrentValidators,
httpStatus, httpGetJson,
snap, expectBlocksSection,
} from './helpers'
const urls = getUrls()
const chainIds = getChainIds()
// ---------------------------------------------------------------------------
// Reachability guards
// ---------------------------------------------------------------------------
async function skipExchangeIfDown(request: any) {
const status = await httpStatus(request, urls.exchange, 10_000)
if (status === 0) {
test.skip(true, 'lux.exchange is unreachable')
}
}
async function skipBridgeIfDown(request: any) {
const status = await httpStatus(request, urls.bridge, 10_000)
if (status === 0) {
test.skip(true, 'bridge.lux.network is unreachable')
}
}
// ── DEX — Swap UI ───────────────────────────────────────────────────
test.describe('DeFi — DEX Swap Interface', () => {
test('exchange loads swap UI', async ({ request, page }) => {
await skipExchangeIfDown(request)
const res = await page.goto(urls.exchange, { timeout: 30_000, waitUntil: 'domcontentloaded' })
expect(res?.status()).toBe(200)
await page.waitForTimeout(3000)
const bodyLen = (await page.textContent('body'))?.length || 0
expect(bodyLen).toBeGreaterThan(0)
await snap(page, 'defi-exchange-home')
})
test('exchange has token selectors', async ({ request, page }) => {
await skipExchangeIfDown(request)
await page.goto(`${urls.exchange}/swap`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(5000)
const tokenUI = page.locator(
'button:has-text("Select"), button:has-text("LUX"), [class*="token"], [class*="Token"], ' +
'[class*="swap"], [class*="Swap"], [class*="currency"], [class*="Currency"]'
).first()
const visible = await tokenUI.isVisible({ timeout: 10_000 }).catch(() => false)
if (visible) {
await snap(page, 'defi-swap-tokens')
}
expect(await page.title()).toBeTruthy()
})
test('exchange pool page shows liquidity pools', async ({ request, page }) => {
await skipExchangeIfDown(request)
await page.goto(`${urls.exchange}/pool`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const bodyLen = (await page.textContent('body'))?.length || 0
expect(bodyLen).toBeGreaterThan(0)
await snap(page, 'defi-pools')
})
})
// ── DEX — Smart Contracts ───────────────────────────────────────────
test.describe('DeFi — DEX Contracts', () => {
test('Uniswap V2 factory contract is deployed', async ({ request }) => {
// Standard Uniswap V2 factory — allPairsLength() selector = 0x574f2ba3
const result = await rpcCall(request, urls.rpc, 'eth_call', [
{
to: '0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f',
data: '0x574f2ba3',
},
'latest',
])
if (result && result !== '0x') {
const pairCount = parseInt(result as string, 16)
console.log(` V2 pair count: ${pairCount}`)
expect(pairCount).toBeGreaterThanOrEqual(0)
} else {
// Contract may not be at canonical address on Lux
console.log(' V2 factory not at canonical address (chain uses custom deployment)')
}
})
test('Uniswap V3 factory contract is deployed', async ({ request }) => {
// Uniswap V3 factory — owner() selector = 0x8da5cb5b
const result = await rpcCall(request, urls.rpc, 'eth_call', [
{
to: '0x1F98431c8aD98523631AE4a59f267346ea31F984',
data: '0x8da5cb5b',
},
'latest',
])
if (result && result !== '0x') {
console.log(` V3 factory owner: ${result}`)
} else {
console.log(' V3 factory not at canonical address (chain uses custom deployment)')
}
// Test passes regardless — we just check that the RPC call works
})
test('DEX router contract responds', async ({ request }) => {
// Uniswap V2 Router02 — factory() selector = 0xc45a0155
const result = await rpcCall(request, urls.rpc, 'eth_call', [
{
to: '0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D',
data: '0xc45a0155',
},
'latest',
])
if (result && result !== '0x') {
console.log(` Router factory: ${result}`)
} else {
console.log(' V2 router not at canonical address (chain uses custom deployment)')
}
})
})
// ── Bridge ──────────────────────────────────────────────────────────
test.describe('DeFi — Bridge', () => {
test('bridge.lux.network loads', async ({ request, page }) => {
await skipBridgeIfDown(request)
const res = await page.goto(urls.bridge, { timeout: 60_000, waitUntil: 'domcontentloaded' })
const status = res?.status() || 0
expect([200, 301, 302, 307]).toContain(status)
await snap(page, 'defi-bridge-home')
})
test('bridge shows chain selectors', async ({ request, page }) => {
await skipBridgeIfDown(request)
await page.goto(urls.bridge, { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(5000)
const chainUI = page.locator(
'[class*="chain"], [class*="Chain"], [class*="network"], [class*="Network"], ' +
'text=/from/i, text=/to/i, text=/lux/i, text=/ethereum/i'
).first()
const visible = await chainUI.isVisible({ timeout: 10_000 }).catch(() => false)
if (visible) {
await snap(page, 'defi-bridge-chains')
}
expect(await page.title()).toBeTruthy()
})
test('bridge API health', async ({ request }) => {
const status = await httpStatus(request, `${urls.bridge}/api/v1/status`)
console.log(` Bridge API: HTTP ${status}`)
// Endpoint may not exist yet; any non-zero response means server is alive
expect(status).toBeGreaterThan(0)
})
test('MPC nodes are running', async ({ request }) => {
const status = await httpStatus(request, urls.mpc)
console.log(` MPC HTTP: ${status}`)
// 200, 301, 302, 307, 403 all indicate service is live
expect(status).toBeGreaterThan(0)
expect(status).toBeLessThan(500)
})
})
// ── Explorer — Transaction Recording ────────────────────────────────
test.describe('DeFi — Explorer Transaction Recording', () => {
test('explorer shows recent blocks', async ({ page }) => {
await expectBlocksSection(page, urls.exploreLux, 30_000)
await snap(page, 'defi-explorer-blocks')
})
test('explorer shows recent transactions', async ({ page }) => {
await page.goto(`${urls.exploreLux}/txs`, { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const bodyLen = (await page.textContent('body'))?.length || 0
expect(bodyLen).toBeGreaterThan(0)
await snap(page, 'defi-explorer-txs')
})
test('explorer API returns block stats', async ({ request }) => {
const data = await httpGetJson(request, `${urls.apiExploreLux}/stats`) as Record<string, unknown>
const blocks = Number(data.total_blocks || 0)
const txs = Number(data.total_transactions || 0)
console.log(` Blocks: ${blocks}, Transactions: ${txs}`)
expect(blocks).toBeGreaterThan(0)
})
test('explorer API returns transactions', async ({ request }) => {
const data = await httpGetJson(request, `${urls.apiExploreLux}/transactions`) as { items: unknown[] }
console.log(` Transactions returned: ${data.items?.length ?? 0}`)
expect(data).toBeDefined()
})
test('explorer API search works', async ({ request }) => {
const status = await httpStatus(
request,
`${urls.apiExploreLux}/search?q=0x0000000000000000000000000000000000000000`,
)
expect([200, 404]).toContain(status)
})
})
// ── Node RPC — C-Chain ──────────────────────────────────────────────
test.describe('DeFi — C-Chain RPC', () => {
test('eth_blockNumber returns valid block', async ({ request }) => {
const block1 = await ethBlockNumber(request, urls.rpc)
// Wait for potential new block (blocks may not advance every second in POA)
await new Promise(r => setTimeout(r, 5000))
const block2 = await ethBlockNumber(request, urls.rpc)
console.log(` Block #1: ${block1}, Block #2: ${block2}`)
// Block should be stable or increasing (POA may not produce blocks without txs)
expect(block2).toBeGreaterThanOrEqual(block1)
expect(block1).toBeGreaterThan(0)
})
test('eth_gasPrice returns non-zero', async ({ request }) => {
const gas = await ethGasPrice(request, urls.rpc)
console.log(` Gas price: ${Number(gas) / 1e9} gwei`)
expect(gas).toBeGreaterThan(0n)
})
test('eth_chainId returns correct chain', async ({ request }) => {
const cid = await ethChainId(request, urls.rpc)
console.log(` Chain ID: ${cid}`)
expect(cid).toBe(chainIds.lux)
})
test('net_version works', async ({ request }) => {
const version = await rpcCall(request, urls.rpc, 'net_version') as string
console.log(` net_version: ${version}`)
expect(version).toBeTruthy()
})
test('web3_clientVersion returns version string', async ({ request }) => {
const version = await rpcCall(request, urls.rpc, 'web3_clientVersion') as string
console.log(` client: ${version}`)
// C-Chain may return just a semver (e.g. "v0.8.12") or include "lux"
expect(version).toBeTruthy()
expect(version.length).toBeGreaterThan(0)
})
})
// ── Node RPC — P-Chain ──────────────────────────────────────────────
test.describe('DeFi — P-Chain RPC', () => {
test('platform.getHeight returns block height', async ({ request }) => {
const result = await rpcCall(request, urls.rpcPlatform, 'platform.getHeight', {}) as { height: string }
const height = Number(result.height)
console.log(` P-Chain height: ${height}`)
// P-Chain height may be 0 during initial bootstrap; must be non-negative
expect(height).toBeGreaterThanOrEqual(0)
})
test('platform.getMinStake returns staking params', async ({ request }) => {
const result = await rpcCall(request, urls.rpcPlatform, 'platform.getMinStake', {}) as {
minValidatorStake: string
minDelegatorStake: string
}
console.log(` minValidatorStake: ${result.minValidatorStake}`)
console.log(` minDelegatorStake: ${result.minDelegatorStake}`)
expect(Number(result.minValidatorStake)).toBeGreaterThan(0)
expect(Number(result.minDelegatorStake)).toBeGreaterThan(0)
})
test('platform.getStakingAssetID returns LUX', async ({ request }) => {
const result = await rpcCall(request, urls.rpcPlatform, 'platform.getStakingAssetID', {}) as { assetID: string }
console.log(` Staking asset: ${result.assetID}`)
expect(result.assetID).toBeTruthy()
})
})
// ── Node RPC — X-Chain ──────────────────────────────────────────────
test.describe('DeFi — X-Chain RPC', () => {
test('avm.getBlockchains lists X-Chain', async ({ request }) => {
// X-Chain RPC uses the info endpoint for cross-chain queries
const result = await rpcCall(request, urls.rpcPlatform, 'platform.getBlockchains', {}) as {
blockchains: { name: string; id: string }[]
}
const names = result.blockchains.map(c => c.name)
console.log(` Blockchains: ${names.join(', ')}`)
expect(result.blockchains.length).toBeGreaterThanOrEqual(3)
})
})