Files
universe/e2e/helpers.ts
T

571 lines
25 KiB
TypeScript

import { Page, APIRequestContext, expect } from '@playwright/test'
import {
env, apiBase, CHAIN_IDS, CREDS, LIVENESS, NETWORKS, orgRpc, PASSWORD_HINT,
shotPath, viewportName,
} from './env'
// ---------------------------------------------------------------------------
// Environment & URL resolution — DERIVED, never redeclared.
// ---------------------------------------------------------------------------
// e2e/env.ts is the one definition (it reads ../NETWORKS.yaml at runtime). This
// file used to keep its OWN copies of the api-domain, explorer-domain and
// chainId tables, and they had already drifted from playwright.config.ts's
// copies. Deleting the duplicates is what fixed the testnet explorer host for
// every spec at once.
//
// The split: env.ts holds the FACTS (urls, chain IDs, viewports, credentials),
// this file holds the ACTIONS (rpc calls, http probes, screenshots). Specs take
// facts from './env' and actions from './helpers'. The accessors below exist
// because the 20 specs already call them — they are derivations, not copies.
export { orgRpc, CHAIN_IDS }
export type LuxUrls = typeof env.urls
// Lux's API host per network, computed from the one derivation in env.ts, for
// the specs that deliberately reach across networks at once (14-network-
// independence). Other orgs need no table of their own: every org is a sovereign
// L1 serving its own C-Chain from api.<org>.network on the flat /v1/chain/C/rpc
// path, which is exactly what orgRpc(org, network) returns. The four hand-kept
// per-org domain tables this file used to carry are gone.
export const API_DOMAINS: Record<string, string> =
Object.fromEntries(NETWORKS.map((n) => [n, apiBase('lux', n)]))
export function getUrls(): LuxUrls {
return env.urls
}
export function getEnv(): string {
return env.name
}
export function getNetwork(): string {
return env.network
}
export function isLocal(): boolean {
return env.isLocal
}
export function getChainIds() {
return env.chainIds
}
// ---------------------------------------------------------------------------
// JSON-RPC helpers
// ---------------------------------------------------------------------------
export async function rpcCall(
request: APIRequestContext,
url: string,
method: string,
params: unknown = [],
timeout = 15_000,
retries = 3,
): Promise<unknown> {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const res = await request.post(url, {
data: { jsonrpc: '2.0', method, params, id: 1 },
headers: { 'Content-Type': 'application/json' },
ignoreHTTPSErrors: true,
timeout,
})
if (res.status() === 429 || res.status() >= 500) {
if (attempt < retries) { await new Promise(r => setTimeout(r, 1000 * (attempt + 1))); continue }
}
// Say what the gateway actually did. A non-JSON body here used to surface
// as `Unexpected token '4'` from the JSON parser, which reads like a
// parser bug rather than what it is: the public gateway serving "404 page
// not found" for a chain that has fallen out of it. Measured on
// api.lux-dev.network mid-run, 5 probes out of 5.
const body = await res.text()
let json: { error?: unknown; result?: unknown }
try {
json = JSON.parse(body)
} catch {
throw new Error(`RPC ${method} on ${url}: HTTP ${res.status()} and the body is not JSON — `
+ `"${body.slice(0, 120).replace(/\s+/g, ' ')}"`)
}
if (json.error) throw new Error(`RPC ${method}: ${JSON.stringify(json.error)}`)
return json.result
} catch (e) {
if (attempt < retries) { await new Promise(r => setTimeout(r, 1000 * (attempt + 1))); continue }
throw e
}
}
}
export async function ethBlockNumber(request: APIRequestContext, rpcUrl: string): Promise<number> {
const result = await rpcCall(request, rpcUrl, 'eth_blockNumber') as string
return parseInt(result, 16)
}
export async function ethChainId(request: APIRequestContext, rpcUrl: string): Promise<number> {
const result = await rpcCall(request, rpcUrl, 'eth_chainId') as string
return parseInt(result, 16)
}
export async function ethGasPrice(request: APIRequestContext, rpcUrl: string): Promise<bigint> {
const result = await rpcCall(request, rpcUrl, 'eth_gasPrice') as string
return BigInt(result)
}
// eth_call / eth_getCode — the one way for read-only contract inspection over any
// C-Chain RPC. `data` is the 4-byte selector + ABI-encoded args (hex, 0x-prefixed).
export async function ethCall(
request: APIRequestContext,
rpcUrl: string,
to: string,
data: string,
block: string = 'latest',
): Promise<string> {
return await rpcCall(request, rpcUrl, 'eth_call', [{ to, data }, block]) as string
}
export async function ethGetCode(
request: APIRequestContext,
rpcUrl: string,
address: string,
block: string = 'latest',
): Promise<string> {
return await rpcCall(request, rpcUrl, 'eth_getCode', [address, block]) as string
}
// A contract is "deployed" iff eth_getCode returns more than the empty "0x".
export function hasCode(code: string | null | undefined): boolean {
return !!code && code !== '0x' && code.length > 2
}
// Decode the trailing 20 bytes of a 32-byte ABI word into a lowercased address.
// Right-aligned per the ABI (an `address` return occupies the low 20 bytes).
export function decodeAddress(word: string): string {
return ('0x' + word.slice(-40)).toLowerCase()
}
// ABI-encode a single uint256 argument to a 32-byte hex word (no 0x prefix),
// for appending to a 4-byte selector when building `data`.
export function encodeUint(value: number | bigint): string {
return BigInt(value).toString(16).padStart(64, '0')
}
export async function getNetworkId(request: APIRequestContext, infoUrl: string): Promise<string> {
const result = await rpcCall(request, infoUrl, 'info.getNetworkID', {}) as { networkID: string }
return result.networkID
}
export async function isBootstrapped(request: APIRequestContext, infoUrl: string, chain: string): Promise<boolean> {
const result = await rpcCall(request, infoUrl, 'info.isBootstrapped', { chain }) as { isBootstrapped: boolean }
return result.isBootstrapped
}
export async function getNodeVersion(request: APIRequestContext, infoUrl: string): Promise<string> {
const result = await rpcCall(request, infoUrl, 'info.getNodeVersion', {}) as { version: string }
return result.version
}
export async function getPeers(request: APIRequestContext, infoUrl: string): Promise<unknown[]> {
const result = await rpcCall(request, infoUrl, 'info.peers', { nodeIDs: [] }) as { peers: unknown[] }
return result.peers || []
}
export async function getCurrentValidators(request: APIRequestContext, platformUrl: string): Promise<unknown[]> {
const result = await rpcCall(request, platformUrl, 'platform.getCurrentValidators', { subnetID: '' }) as { validators: unknown[] }
return result.validators || []
}
export async function getBlockchains(request: APIRequestContext, platformUrl: string): Promise<unknown[]> {
const result = await rpcCall(request, platformUrl, 'platform.getBlockchains', {}) as { blockchains: unknown[] }
return result.blockchains || []
}
// ---------------------------------------------------------------------------
// Chain LIVENESS — the tip, not the readiness probe
// ---------------------------------------------------------------------------
// Kubernetes said 5/5 Ready on fifteen pods and every public API said 200 while
// mainnet had not produced a block in 58.9 hours. Readiness is a claim the node
// makes about itself; the tip is a fact you can check. Everything here reads
// the tip.
export interface Tip {
number: number
hash: string
/** Block timestamp, unix seconds — what the CHAIN says the time was. */
timestamp: number
/** Wall-clock seconds since that timestamp, at the moment of the call. */
ageSec: number
}
export async function getBlock(
request: APIRequestContext,
rpcUrl: string,
tag: string | number = 'latest',
): Promise<{ number: string; hash: string; timestamp: string } | null> {
const param = typeof tag === 'number' ? '0x' + tag.toString(16) : tag
return await rpcCall(request, rpcUrl, 'eth_getBlockByNumber', [param, false]) as
{ number: string; hash: string; timestamp: string } | null
}
export async function getTip(request: APIRequestContext, rpcUrl: string): Promise<Tip> {
const b = await getBlock(request, rpcUrl, 'latest')
if (!b) throw new Error(`eth_getBlockByNumber(latest) returned null on ${rpcUrl}`)
const timestamp = parseInt(b.timestamp, 16)
return {
number: parseInt(b.number, 16),
hash: b.hash,
timestamp,
ageSec: Math.round(Date.now() / 1000) - timestamp,
}
}
/**
* The chain's OWN recent cadence, in seconds — median gap over the last
* LIVENESS.gapSamples blocks. Median, not mean: one 27,375-second gap (which is
* exactly what the mainnet freeze looks like from inside the block list) drags
* a mean past any threshold and would let the stall define its own budget.
*/
export async function medianBlockGap(
request: APIRequestContext,
rpcUrl: string,
samples = LIVENESS.gapSamples,
): Promise<{ median: number; gaps: number[] }> {
const tip = await getTip(request, rpcUrl)
const times: number[] = [tip.timestamp]
for (let i = 1; i <= samples && tip.number - i >= 0; i++) {
const b = await getBlock(request, rpcUrl, tip.number - i)
if (!b) break
times.push(parseInt(b.timestamp, 16))
}
const gaps: number[] = []
for (let i = 0; i < times.length - 1; i++) gaps.push(times[i] - times[i + 1])
if (gaps.length === 0) return { median: 0, gaps }
const sorted = [...gaps].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
const median = sorted.length % 2 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2)
return { median, gaps }
}
/** How old the tip is allowed to be, derived from the chain's own cadence. */
export function staleBudgetSec(medianGap: number): number {
return Math.max(medianGap * LIVENESS.staleGapMultiple, LIVENESS.staleFloorSec)
}
export function formatAge(sec: number): string {
if (sec < 90) return `${sec}s`
if (sec < 5400) return `${(sec / 60).toFixed(1)}min`
return `${(sec / 3600).toFixed(1)}h`
}
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------
export async function httpStatus(
request: APIRequestContext,
url: string,
timeout = 15_000
): Promise<number> {
try {
const res = await request.get(url, { ignoreHTTPSErrors: true, timeout })
return res.status()
} catch {
return 0
}
}
// Reads JSON, and when there is no JSON to read, SAYS SO. This used to be a
// bare `res.json()`, which handed the body of any error page straight to
// JSON.parse. A plain-text `404 page not found` then failed as `Unexpected
// non-whitespace character after JSON at position 4` — a complaint about the
// fifth byte of the phrase, naming neither the URL nor the status. Every
// caller wants the same two facts on failure: which endpoint, and what it
// actually served.
export async function httpGetJson(
request: APIRequestContext,
url: string,
timeout = 15_000
): Promise<unknown> {
const res = await request.get(url, {
ignoreHTTPSErrors: true,
timeout,
headers: { Accept: 'application/json' },
})
const body = await res.text()
const how = `GET ${url} → ${res.status()} ${res.headers()['content-type'] || 'no content-type'}`
if (!res.ok()) throw new Error(`${how}: ${body.slice(0, 200)}`)
try {
return JSON.parse(body)
} catch {
throw new Error(`${how}, body is not JSON: ${body.slice(0, 200)}`)
}
}
// ---------------------------------------------------------------------------
// "The explorer is showing its blocks" — asserted ONE way.
// ---------------------------------------------------------------------------
// Two specs used to assert this as `locator('text=/block/i').first()`, which
// resolves to a Radix menu TRIGGER whose label contains "block": hidden at
// 390/834, visible at 1440. Both tests therefore tracked the nav's collapse
// rather than the blocks table, and both went red the moment the suite gained
// mobile and tablet frames — while the explorer was in fact rendering
// "Total blocks 1,098,192" and four block links at all three (measured
// 2026-07-26). This asserts the section AND a link to a real block, which the
// old selector could never do: the heading "Total blocks 1,098,192" stays
// visible even when the widget below it says "No latest blocks found".
//
// ⚠ MEASURED DEFECT (explore.lux.network, 2026-07-26) — the Latest-blocks widget
// renders "No latest blocks found" under CONCURRENT cold loads, while the same
// page still shows the right total and the indexer endpoint behind it is fine
// (`/v1/indexer/cchain/blocks?limit=3` → 200 with 3 blocks on 12/12 polls;
// 10/10 serial page loads render 4 block links; 1 failure per 126 cases at 4
// workers, 0 at 1 worker). The widget has no retry and falls to its empty state
// on a dropped fetch. Navigation + one reload therefore live HERE, so the retry
// guards exactly what is asserted — the previous loop re-checked the *heading*,
// which is visible in the broken state, so it always broke out of the loop and
// then failed the link assertion. A genuinely empty explorer still goes RED:
// both attempts have to render blocks.
export async function expectBlocksSection(page: Page, url: string, timeout = 15_000) {
const heading = page.getByText(/latest blocks|total blocks/i).first()
const blockLink = page.locator('a[href*="/block/"], a[href*="/blocks/"]').first()
for (let attempt = 0; attempt < 2; attempt++) {
await page.goto(url, { waitUntil: 'domcontentloaded' })
const shown = await heading.isVisible({ timeout }).catch(() => false)
&& await blockLink.isVisible({ timeout: 5_000 }).catch(() => false)
if (shown) break
if (attempt === 0) console.log(' explorer served no latest blocks — reloading once')
}
await expect(heading, 'no visible blocks section on the explorer').toBeVisible({ timeout })
await expect(blockLink,
'explorer renders no link to a block ("No latest blocks found") after a reload').toBeVisible({ timeout })
}
// ---------------------------------------------------------------------------
// "The page actually rendered" — the ONE assertion for a route sweep
// ---------------------------------------------------------------------------
// Three of the surfaces in the registry answer 200 for a path that does not
// exist (lux.cloud, exchange, bridge — measured with a /zzz-bogus control), so
// a status-code sweep across them would report full coverage of nothing. This
// is the replacement: it looks at what the browser PAINTED.
//
// Everything asserted here is a POSITIVE signal — text was rendered, a real
// landmark element exists. Nothing asserts the absence of an error string;
// "no 'error' in the body" is how two runs were declared green today while the
// screen was blank. hanzo.vote is the shape this catches: HTTP 200, and
// `#root` with zero children on 6 of 6 loads.
export interface Render {
url: string
status: number | null
textLen: number
rootKids: number
heading: string
landmark: boolean
/** The framework's own error boundary is on screen (see CRASH_SCREENS). */
crashed: string | null
}
// The error boundaries these apps actually render, measured verbatim. Matching
// them is a POSITIVE detection — "this page is showing its crash screen" — not
// the absent-error-string check that produced two false passes today. A crashed
// page paints plenty of text and would sail through a length assertion:
// exchange /pool serves HTTP 200 with 553 characters, all of which say
// "Something went wrong. Sorry, an error occured while processing your request".
export const CRASH_SCREENS = [
'something went wrong',
'an error occured while processing your request',
'an error occurred while processing your request',
'application error: a client-side exception',
'unhandled runtime error',
'this page could not be found',
]
/** Load a route and report what it painted. Never throws on a bad page. */
export async function renderRoute(page: Page, url: string, settleMs = 2500): Promise<Render> {
let status: number | null = null
try {
const res = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45_000 })
status = res?.status() ?? null
} catch {
status = null
}
await page.waitForTimeout(settleMs)
const raw = await page.evaluate((crashes: string[]) => {
const root = document.querySelector('#root, #__next, main, body')
const h = document.querySelector('h1, h2, [role="heading"]')
const text = (document.body?.innerText ?? '').trim()
const lower = text.toLowerCase()
return {
url: location.href,
textLen: text.length,
rootKids: root ? root.children.length : 0,
heading: (h?.textContent ?? '').trim().slice(0, 80),
landmark: !!document.querySelector('header, nav, main, [role="main"], [role="navigation"]'),
crashed: crashes.find((c) => lower.includes(c)) ?? null,
}
}, [...CRASH_SCREENS])
return { ...raw, status }
}
/**
* A page counts as painted when it rendered real text into a non-empty root.
*
* NOT a semantic-landmark check. A first attempt required a header/nav/main and
* failed Safe (all 31 routes), MPC and the admin console — all three lay out in
* plain divs and all three render perfectly. That assertion tracked markup
* fashion, not health. `landmark` is still recorded and logged; it is just not
* a gate.
*
* MEASURED thresholds: the thinnest genuinely-working page in the registry is
* superadmin at 62 characters ("Lux Admin SuperAdmin Sign in with Lux IAM to
* continue. Sign in"); a white screen is exactly 0 characters and 0 children
* (exchange /vote, reproduced on every load). 40 sits between them with room on
* both sides.
*/
export const MIN_RENDERED_TEXT = 40
export async function expectRendered(page: Page, url: string, what: string): Promise<Render> {
let r = await renderRoute(page, url)
// ONE reload if the first paint failed, and only then. Measured: the
// explorer's /stats and /advanced-filter serve their error boundary under
// CONCURRENT cold load and render perfectly on a solo load (3/3), the same
// no-retry-on-a-dropped-fetch weakness already documented in
// expectBlocksSection. The exchange's /pool, /positions, /bridge, /teleport
// and /vote fail on a SOLO load too — so one reload is exactly the line
// between "the page has no retry" and "the page is broken", and the broken
// ones stay red. This is not a way to get green: both attempts have to fail
// for a route to pass, and nothing is retried that painted correctly.
if (r.crashed || r.textLen < MIN_RENDERED_TEXT) {
console.log(` ${url} → ${r.crashed ?? 'white screen'} on first paint, reloading once`)
r = await renderRoute(page, url, 5_000)
}
// 1. It painted something.
expect(r.textLen,
`${what} is a WHITE SCREEN — ${url} → HTTP ${r.status}, body text ${r.textLen} chars, `
+ `root children ${r.rootKids}`).toBeGreaterThanOrEqual(MIN_RENDERED_TEXT)
expect(r.rootKids,
`${what} rendered text but its root has no children — ${url}`).toBeGreaterThan(0)
// 2. What it painted is the page, not the app's error boundary. Separate
// assertion because a crash screen passes (1) with hundreds of characters.
expect(r.crashed,
`${what} is serving its CRASH SCREEN at HTTP ${r.status} — ${url} rendered `
+ `"${r.crashed}". The route resolves and the app throws.`).toBe(null)
return r
}
// ---------------------------------------------------------------------------
// Which door the exchange answers on
// ---------------------------------------------------------------------------
// One product, two hostnames. `lux.exchange` is the vanity domain and the one
// an investor types; `exchange.lux.network` is the same deployment addressed by
// its cluster name. MEASURED 2026-07-26 from this network: the vanity host
// fails the TLS handshake in curl, python and chromium alike
// (net::ERR_SSL_PROTOCOL_ERROR), the cluster host serves 200 on all 22 routes.
//
// The suite must not go red for a TLS blocklist between this laptop and a CDN,
// and it must not silently skip 36 tests either — which is what it did. So:
// probe the vanity host once per worker, use it if it answers, otherwise fall
// through to the origin and SAY SO in the log. Whether lux.exchange is
// reachable is asserted separately and on purpose, in 02-exchange.
let exchangeDoor: string | null = null
export async function exchangeBase(request: APIRequestContext): Promise<string> {
if (exchangeDoor) return exchangeDoor
const vanity = env.urls.exchange
const status = await httpStatus(request, vanity, 12_000)
exchangeDoor = status > 0 ? vanity : env.urls.exchangeOrigin
if (status === 0) {
console.log(` ${new URL(vanity).host} unreachable from here — using `
+ `${new URL(env.urls.exchangeOrigin).host} (same deployment)`)
}
return exchangeDoor
}
// ---------------------------------------------------------------------------
// Screenshot helper — the ONE deliberate screenshot path.
// ---------------------------------------------------------------------------
// ~/Desktop/lux-e2e/<env>/<surface>-<viewport>.png. The viewport comes from the
// page itself, so the same call in the same spec lands in a distinct file per
// Playwright project — one frame per viewport, comparable across runs and envs.
// Failure evidence is the orthogonal concern and belongs to shot-reporter.ts.
export async function snap(page: Page, surface: string) {
const path = shotPath(surface, viewportName(page.viewportSize()))
try {
await page.screenshot({ path, fullPage: false, timeout: 30_000 })
} catch {
console.log(`WARN: screenshot ${surface} timed out, skipping`)
}
}
// ---------------------------------------------------------------------------
// Latency benchmark
// ---------------------------------------------------------------------------
export async function benchmarkRpc(
request: APIRequestContext,
url: string,
method: string,
params: unknown = [],
iterations = 10
): Promise<{ avg: number; p50: number; p99: number; errors: number }> {
const times: number[] = []
let errors = 0
for (let i = 0; i < iterations; i++) {
const t0 = Date.now()
try {
await rpcCall(request, url, method, params, 10_000)
times.push(Date.now() - t0)
} catch {
errors++
}
}
if (times.length === 0) return { avg: 0, p50: 0, p99: 0, errors }
times.sort((a, b) => a - b)
const avg = times.reduce((a, b) => a + b, 0) / times.length
const p50 = times[Math.floor(times.length / 2)]
const p99 = times[times.length - 1]
return { avg: Math.round(avg), p50: Math.round(p50), p99: Math.round(p99), errors }
}
// ---------------------------------------------------------------------------
// Sign-in — the one flow, so an authenticated spec never re-derives it
// ---------------------------------------------------------------------------
// 50-login.spec.ts is the PROOF that this flow is real (token claims, userinfo
// 200, sign-out present). Every other authenticated spec just needs to *be*
// signed in, and must not carry a second copy of the click path — the second
// copy is what silently sends a run to github.com/login when the accessible
// name of a button changes.
//
// Returns the access token the sign-in produced, so a caller can assert on its
// claims. NEVER log the return value.
export const IAM_TOKEN_KEY = 'hanzo_iam_access_token'
export async function signIn(page: Page): Promise<string> {
expect(CREDS.password, PASSWORD_HINT).not.toBe('')
const cloudHost = new URL(env.urls.cloud).host
const idpHost = new URL(env.urls.id).host
const esc = (h: string) => h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
await page.goto(`${env.urls.cloud}/login`, { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(2500)
await page.getByRole('button', { name: /continue with/i }).first().click()
await page.waitForURL(new RegExp(esc(idpHost)), { timeout: 45_000 })
await page.locator('input[type=password]').waitFor({ state: 'visible', timeout: 30_000 })
await page.locator('input[type=email], input[type=text]').first().fill(CREDS.user)
await page.locator('input[type=password]').first().fill(CREDS.password)
// EXACT accessible name — `button[type=submit]` matches "Continue with GitHub".
await page.getByRole('button', { name: /^(sign in|log in)$/i }).first().click()
await page.waitForURL(new RegExp(esc(cloudHost) + '/dashboard'), { timeout: 60_000 })
.catch(() => {})
await page.waitForTimeout(5000)
const jwt = await page.evaluate((k) => sessionStorage.getItem(k), IAM_TOKEN_KEY)
expect(jwt, `no ${IAM_TOKEN_KEY} in sessionStorage — the code→token exchange failed `
+ `(landed on ${page.url()})`).toBeTruthy()
return jwt!
}