Files
universe/e2e/07-validator.spec.ts
zeekayandHanzo Dev f8895b9a54 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

202 lines
7.9 KiB
TypeScript

import { test, expect } from '@playwright/test'
import {
getUrls, getNetwork, isLocal,
rpcCall, ethBlockNumber, ethChainId, getNetworkId,
isBootstrapped, getNodeVersion, getPeers,
getCurrentValidators, getBlockchains,
httpStatus,
} from './helpers'
/**
* Local validator tests — verify a node can run locally and connect to cloud.lux.network bootnode.
*
* These tests validate:
* 1. Local node is running and healthy
* 2. Node connects to peers via cloud.lux.network bootnode
* 3. All chains bootstrap successfully
* 4. Validator can be registered on P-Chain
*
* Run with: LUX_ENV=local npx playwright test e2e/07-validator.spec.ts
*/
const urls = getUrls()
const network = getNetwork()
// This suite targets a LOCAL validator node (LUX_ENV=local). Against production the
// node health/info API is firewalled by design, so there is no local node to
// probe — skip the whole suite unless running locally.
test.beforeEach(() => {
test.skip(!isLocal(), 'local validator suite — run with LUX_ENV=local')
})
// ── Local Node Health ───────────────────────────────────────────────
test.describe('Local Validator — Node Health', () => {
test('local node is running', async ({ request }) => {
const status = await httpStatus(request, urls.rpcHealth, 10_000)
console.log(` Local node health: HTTP ${status}`)
if (status === 0) {
console.log(` NOTE: Start local node with: make start network=${network.replace('net', '')}`)
test.skip(true, 'Local node not running')
}
// 200 = healthy, 503 = still bootstrapping (acceptable for local)
expect([200, 503]).toContain(status)
})
test('local node reports healthy', async ({ request }) => {
const status = await httpStatus(request, urls.rpcHealth, 5_000)
if (status === 0) test.skip(true, 'Local node not running')
let healthy = false
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await request.get(urls.rpcHealth, { ignoreHTTPSErrors: true, timeout: 10_000 })
const json = await res.json()
healthy = json.healthy ?? json.result?.healthy ?? false
if (healthy) break
} catch {
if (attempt < 2) await new Promise(r => setTimeout(r, 1000))
}
}
expect(healthy).toBe(true)
})
test('local node version', async ({ request }) => {
const status = await httpStatus(request, urls.rpcHealth, 5_000)
if (status === 0) test.skip(true, 'Local node not running')
const ver = await getNodeVersion(request, urls.rpcInfo)
console.log(` Node version: ${ver}`)
expect(ver).toBeTruthy()
expect(ver.toLowerCase()).toContain('lux')
})
})
// ── Local Node — Chain Bootstrap ────────────────────────────────────
test.describe('Local Validator — Chain Bootstrap', () => {
test('P-Chain bootstrapped', async ({ request }) => {
const status = await httpStatus(request, urls.rpcHealth, 5_000)
if (status === 0) test.skip(true, 'Local node not running')
const ok = await isBootstrapped(request, urls.rpcInfo, 'P')
expect(ok).toBe(true)
})
test('C-Chain bootstrapped', async ({ request }) => {
const status = await httpStatus(request, urls.rpcHealth, 5_000)
if (status === 0) test.skip(true, 'Local node not running')
const ok = await isBootstrapped(request, urls.rpcInfo, 'C')
expect(ok).toBe(true)
})
test('X-Chain bootstrapped', async ({ request }) => {
const status = await httpStatus(request, urls.rpcHealth, 5_000)
if (status === 0) test.skip(true, 'Local node not running')
const ok = await isBootstrapped(request, urls.rpcInfo, 'X')
expect(ok).toBe(true)
})
test('C-Chain produces blocks', async ({ request }) => {
const status = await httpStatus(request, urls.rpcHealth, 5_000)
if (status === 0) test.skip(true, 'Local node not running')
const block = await ethBlockNumber(request, urls.rpc)
console.log(` C-Chain block: ${block}`)
expect(block).toBeGreaterThan(0)
})
test('C-Chain has correct chainId', async ({ request }) => {
const status = await httpStatus(request, urls.rpcHealth, 5_000)
if (status === 0) test.skip(true, 'Local node not running')
const cid = await ethChainId(request, urls.rpc)
console.log(` C-Chain chainId: ${cid}`)
// Accept any valid Lux chain ID
expect([96369, 96368, 96367, 1337]).toContain(cid)
})
})
// ── Local Node — Peer Connectivity ──────────────────────────────────
test.describe('Local Validator — Peer Connectivity', () => {
test('connects to peers', async ({ request }) => {
const status = await httpStatus(request, urls.rpcHealth, 5_000)
if (status === 0) test.skip(true, 'Local node not running')
try {
const peers = await getPeers(request, urls.rpcInfo)
console.log(` Peers: ${peers.length}`)
// Local 5-node setup should have 4 peers
expect(peers.length).toBeGreaterThanOrEqual(1)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
// info.peers blocked at gateway (security) — this is expected
if (msg.includes('-32601') || msg.includes('not allowed') || msg.includes('403') || msg.includes('Method not found')) {
console.log(' info.peers blocked at gateway (security) — PASS')
return
}
throw e
}
})
test('network ID is correct', async ({ request }) => {
const status = await httpStatus(request, urls.rpcHealth, 5_000)
if (status === 0) test.skip(true, 'Local node not running')
const nid = await getNetworkId(request, urls.rpcInfo)
console.log(` Network ID: ${nid}`)
expect(['1', '2', '3']).toContain(nid)
})
})
// ── Local Node — Validator Registration ─────────────────────────────
test.describe('Local Validator — P-Chain Validators', () => {
test('P-Chain has validators', async ({ request }) => {
const status = await httpStatus(request, urls.rpcHealth, 5_000)
if (status === 0) test.skip(true, 'Local node not running')
const validators = await getCurrentValidators(request, urls.rpcPlatform) as { nodeID: string }[]
console.log(` Validators: ${validators.length}`)
expect(validators.length).toBeGreaterThanOrEqual(1)
})
test('app chains are deployed', async ({ request }) => {
const status = await httpStatus(request, urls.rpcHealth, 5_000)
if (status === 0) test.skip(true, 'Local node not running')
const chains = await getBlockchains(request, urls.rpcPlatform) as { name: string }[]
const names = chains.map(c => c.name)
console.log(` Chains: ${names.join(', ')}`)
// At minimum P, C, X should be registered
expect(chains.length).toBeGreaterThanOrEqual(3)
})
})
// ── Cloud Bootnode — Connectivity ───────────────────────────────────
test.describe('Cloud Bootnode — cloud.lux.network', () => {
test('cloud.lux.network is reachable', async ({ request }) => {
const status = await httpStatus(request, 'https://cloud.lux.network')
console.log(` cloud.lux.network: HTTP ${status}`)
expect(status).toBeGreaterThan(0)
expect(status).toBeLessThan(500)
})
test('cloud bootnode RPC accessible', async ({ request }) => {
// cloud.lux.network should expose RPC for bootstrap
for (const path of [
'/v1/health',
'/v1/info',
'/api/health',
'/health',
]) {
const status = await httpStatus(request, `https://cloud.lux.network${path}`, 10_000)
if (status > 0 && status < 500) {
console.log(` Bootnode ${path}: HTTP ${status}`)
return
}
}
// Bootnode may not expose health endpoints directly, frontend is enough
console.log(' Bootnode health endpoints not directly exposed (fronted by UI)')
})
})