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]>
281 lines
12 KiB
TypeScript
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)
|
|
})
|
|
})
|