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]>
This commit is contained in:
2026-07-26 15:46:04 -07:00
co-authored by Hanzo Dev
parent 03735de38c
commit e2503dae65
14 changed files with 936 additions and 322 deletions
+52 -20
View File
@@ -564,50 +564,78 @@ test: ## Run tests for core components
##@ E2E Testing (Playwright)
# ONE axis: LUX_ENV=local|devnet|testnet|mainnet (default mainnet). Every url,
# port and chainId is resolved by e2e/env.ts from NETWORKS.yaml — never here.
# ONE viewport set: projects mobile(390x844) tablet(834x1194) laptop(1440x900),
# so "mobile and desktop" is the same three frames every run. Narrow a run with
# `vp=<name>` and a spec with `spec=<path>`.
# Screenshots (pass AND fail) land in ~/Desktop/lux-e2e/$(LUX_ENV)/.
LUX_ENV ?= mainnet
PW := LUX_ENV=$(LUX_ENV) npx playwright test $(if $(vp),--project=$(vp),)
.PHONY: e2e
e2e: ## Run all E2E tests (ENV=production|local, LUX_NETWORK=mainnet|testnet|devnet)
@echo "$(BOLD)Running E2E tests (ENV=$(or $(ENV),production))...$(RESET)"
@npx playwright test
e2e: ## Run all E2E tests (LUX_ENV=local|devnet|testnet|mainnet, vp=mobile|tablet|laptop)
@echo "$(BOLD)E2E — LUX_ENV=$(LUX_ENV)$(if $(vp), project=$(vp),) → ~/Desktop/lux-e2e/$(LUX_ENV)$(RESET)"
@$(PW) $(spec)
.PHONY: e2e-mainnet e2e-testnet e2e-devnet e2e-local
e2e-mainnet: ## Run all E2E against mainnet
@$(MAKE) -s e2e LUX_ENV=mainnet
e2e-testnet: ## Run all E2E against testnet
@$(MAKE) -s e2e LUX_ENV=testnet
e2e-devnet: ## Run all E2E against devnet
@$(MAKE) -s e2e LUX_ENV=devnet
e2e-local: ## Run all E2E against the local stack (make start network=main + ./local/up.sh)
@$(MAKE) -s e2e LUX_ENV=local
.PHONY: e2e-login
e2e-login: ## Prove sign-in for real (needs LUX_E2E_PASSWORD)
@if [ -z "$$LUX_E2E_PASSWORD" ]; then \
echo "$(RED)LUX_E2E_PASSWORD is not set — export it first (never commit it)$(RESET)"; exit 1; fi
@$(PW) e2e/50-login.spec.ts
# The pre-go-live gate: the load-bearing surfaces at all three frames, in
# minutes rather than hours. `make e2e` runs everything including the very large
# legacy broker/exchange specs, which are serial within their own file and so
# dominate a full run.
.PHONY: e2e-gate
e2e-gate: ## Run the go-live gate (chain + validators + defi + bridge + dex + login)
@$(PW) e2e/00-health.spec.ts e2e/01-explorer.spec.ts e2e/08-validator-lifecycle.spec.ts \
e2e/09-defi-flow.spec.ts e2e/13-bridge-mpc.spec.ts e2e/14-dex-contracts.spec.ts \
e2e/14-network-independence.spec.ts e2e/16-app-chains.spec.ts \
$(if $(LUX_E2E_PASSWORD),e2e/50-login.spec.ts,)
.PHONY: e2e-health
e2e-health: ## Run health/RPC E2E tests only
@npx playwright test e2e/00-health.spec.ts
@$(PW) e2e/00-health.spec.ts
.PHONY: e2e-explorer
e2e-explorer: ## Run explorer E2E tests
@npx playwright test e2e/01-explorer.spec.ts
@$(PW) e2e/01-explorer.spec.ts
.PHONY: e2e-exchange
e2e-exchange: ## Run exchange E2E tests
@npx playwright test e2e/02-exchange.spec.ts
@$(PW) e2e/02-exchange.spec.ts
.PHONY: e2e-bridge
e2e-bridge: ## Run bridge E2E tests
@npx playwright test e2e/03-bridge.spec.ts
@$(PW) e2e/03-bridge.spec.ts
.PHONY: e2e-staking
e2e-staking: ## Run staking/delegation E2E tests
@npx playwright test e2e/04-staking.spec.ts
@$(PW) e2e/04-staking.spec.ts
.PHONY: e2e-cloud
e2e-cloud: ## Run cloud bootnode E2E tests
@npx playwright test e2e/05-cloud.spec.ts
@$(PW) e2e/05-cloud.spec.ts
.PHONY: e2e-wallet
e2e-wallet: ## Run wallet E2E tests
@npx playwright test e2e/06-wallet.spec.ts
@$(PW) e2e/06-wallet.spec.ts
.PHONY: e2e-validator
e2e-validator: ## Run local validator E2E tests
@ENV=local npx playwright test e2e/07-validator.spec.ts
.PHONY: e2e-prod
e2e-prod: ## Run all E2E against production
@ENV=production npx playwright test
.PHONY: e2e-local
e2e-local: ## Run all E2E against local nodes
@ENV=local npx playwright test
e2e-validator: ## Run local validator E2E tests (local stack only)
@$(MAKE) -s e2e LUX_ENV=local spec=e2e/07-validator.spec.ts
.PHONY: e2e-install
e2e-install: ## Install Playwright + browsers
@@ -618,6 +646,10 @@ e2e-install: ## Install Playwright + browsers
e2e-report: ## Open Playwright HTML report
@npx playwright show-report
.PHONY: e2e-shots
e2e-shots: ## Open the screenshot directory for this env
@open ~/Desktop/lux-e2e/$(LUX_ENV) 2>/dev/null || echo "no shots yet for LUX_ENV=$(LUX_ENV)"
##@ Development
.PHONY: dev
+105 -1
View File
@@ -101,10 +101,114 @@ LB: `134.199.138.27`. All `*.lux.network` services.
### RPC Endpoints
Each network has its **own host** and serves a **flat `/v1`** path. There is no
`/mainnet` or `/ext` path segment — never hand-build one.
```
https://api.lux.network/{mainnet,testnet,devnet}/v1/bc/{chain}/rpc
mainnet https://api.lux.network/v1/bc/{C,P,X}/rpc chainId 96369
testnet https://api.lux-test.network/v1/bc/{C,P,X}/rpc chainId 96368
devnet https://api.lux-dev.network/v1/bc/{C,P,X}/rpc chainId 96367
```
App chains serve the same shape from their own host: `api.zoo.network`,
`api.hanzo.network`, `api.pars.network` (`-test` / `-dev` suffix per network).
## E2E (Playwright)
One axis, one viewport set, one place where environments are defined.
```bash
make e2e-install # npm ci + chromium
make e2e-gate # the go-live gate — minutes, all three frames
make e2e # everything, mainnet (default), all three viewports
make e2e-testnet # same specs, testnet endpoints
make e2e-devnet
make e2e-local # needs: make start network=main + ./local/up.sh
make e2e vp=mobile # one viewport
make e2e spec=e2e/01-explorer.spec.ts
make e2e-health # per-surface targets honour LUX_ENV too
make e2e-report # HTML report
make e2e-shots # open the screenshots for this env
```
`make e2e` is **~1977 cases** (659 × 3 frames) and takes hours: the very large
legacy broker/exchange specs are serial *within* their own file, so no amount of
workers shortens them. `make e2e-gate` is the subset you run before a go-live —
chain health, explorer, validator lifecycle, DeFi, bridge/MPC, DEX contracts,
network independence, app chains, plus the login proof when `LUX_E2E_PASSWORD` is
exported.
Concurrency is per-environment and measured (`WORKERS` in `e2e/env.ts`), because
the environments do not have the same front door. Mainnet's api host is
CDN-fronted and ran the gate at 4 workers with 342/342 green; one worker and four
returned an identical result set (154 passed / 2 failed) in 5.9m vs 2.3m. Testnet
and devnet resolve straight to the cluster LB with no CDN, and at 4 workers the
same gate lost 66 cases to gateway throttling that all passed at 2 — it is
aggregate load that tips it, so the budget belongs to the environment rather than
to any one spec.
**`LUX_ENV=local|devnet|testnet|mainnet`** (default `mainnet`) is the *only*
environment knob. `e2e/env.ts` resolves every base URL, RPC endpoint, network ID
and chain ID from [`NETWORKS.yaml`](NETWORKS.yaml) — the config, `helpers.ts` and
the specs all derive from that one module, so changing a chain ID in the registry
changes the suite. The old two-axis `ENV=production|local` + `LUX_NETWORK=…` model
is retired and `LUX_NETWORK` now fails loudly instead of being half-honoured.
`local` targets the one local stack (`127.0.0.1:9630` node, `:8899` IAM) and reads
the mainnet column, because `make start network=main` boots a mainnet-genesis
chain. Only RPC and explorers are per-network: every app surface (cloud, id,
wallet, bridge, exchange, staking, mpc, safe) is a single deployment shared by all
remote envs — `test.lux.cloud` and `dev.lux.cloud` are 404.
### Viewports are projects
`mobile 390x844`, `tablet 834x1194`, `laptop 1440x900` — so "mobile and desktop"
means the same three frames on every run, and every screenshot is comparable
across runs and environments.
### Screenshots
`~/Desktop/lux-e2e/<env>/<surface>-<viewport>.png`, written on **pass and fail** —
a failure you cannot look at is a failure you cannot fix. Deliberate frames come
from `snap(page, surface)`; a failing test's dying state is copied out of
Playwright's run directory by `e2e/shot-reporter.ts` as
`fail-<spec>-<test>-<viewport>.png`.
### Proving sign-in
```bash
export LUX_E2E_PASSWORD='…' # never committed; LUX_E2E_USER defaults to [email protected]
make e2e-login
```
`e2e/50-login.spec.ts` drives the real flow — lux.cloud → "Continue with Lux ID"
→ lux.id → `/dashboard` — and refuses the cheap answers, because every cheap
answer lies here:
- HTTP 200 on `/dashboard` proves nothing: it is an SPA, so an anonymous visit is
*also* 200 and *also* stays on the `/dashboard` URL.
- "the password field vanished" proves nothing: the OIDC callback parks on an
interstitial that has no form either, and `/auth/callback` with no code renders
*"Sign-in failed · Missing authorization code in callback URL"* at HTTP 200.
So the proof is the access token: the run ends with a JWT in `sessionStorage`
whose `iss` is the IdP and whose `email` is the account that was typed, and
`GET lux.id/v1/iam/oauth/userinfo` **accepts** it (that endpoint returns 401 for
no token and 401 for a junk token, so a 200 is only reachable with a token the
IdP really minted). That is the exact leg that breaks in an `invalid_client`
regression — the IdP still issues a code and the browser still lands on
`/dashboard`, but the code→token exchange 401s and no token is ever stored. The
spec fails on `Sign-in failed`, `invalid_client`, `PKCE is required`, a missing
authorization code, a parked interstitial, or still being on the IdP.
It also asserts the handoff is a **public PKCE client** (`client_id=lux-cloud`,
`code_challenge_method=S256`, redirect `https://lux.cloud/auth/callback`, no
secret), and it carries its own negative control: a test that proves an anonymous
`/dashboard` holds no token and offers no sign-out, so the positive assertions
cannot go vacuous.
## License
Proprietary - Lux Partners Limited. All rights reserved.
+1 -1
View File
@@ -91,7 +91,7 @@ test.describe('Peer Connectivity', () => {
peers = await getPeers(request, urls.rpcInfo)
} catch {
// info.peers is firewalled at the public gateway by design — peer-mesh
// depth is only assertable against a local node (ENV=local).
// depth is only assertable against a local node (LUX_ENV=local).
console.log(' info.peers not publicly exposed (firewalled) — PASS')
return
}
+2 -11
View File
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test'
import { getUrls, httpStatus, httpGetJson, snap } from './helpers'
import { getUrls, httpStatus, httpGetJson, snap, expectBlocksSection } from './helpers'
const urls = getUrls()
@@ -56,16 +56,7 @@ test.describe('Explorer Frontend — Page Load', () => {
// ── Explorer UI — Core Functionality ────────────────────────────────
test.describe('Explorer UI — Lux C-Chain Navigation', () => {
test('homepage shows blocks table', async ({ page }) => {
// Gateway may serve stale response — retry page load once
for (let attempt = 0; attempt < 2; attempt++) {
await page.goto(urls.exploreLux, { waitUntil: 'domcontentloaded' })
const blocksSection = page.locator('text=/block/i').first()
const visible = await blocksSection.isVisible({ timeout: 15_000 }).catch(() => false)
if (visible) break
if (attempt === 0) await page.reload({ waitUntil: 'domcontentloaded' })
}
const blocksSection = page.locator('text=/block/i').first()
await expect(blocksSection).toBeVisible({ timeout: 15_000 })
await expectBlocksSection(page, urls.exploreLux)
await snap(page, 'explorer-lux-homepage')
})
+3 -3
View File
@@ -16,17 +16,17 @@ import {
* 3. All chains bootstrap successfully
* 4. Validator can be registered on P-Chain
*
* Run with: ENV=local npx playwright test e2e/07-validator.spec.ts
* 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 (ENV=local). Against production the
// 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 ENV=local')
test.skip(!isLocal(), 'local validator suite — run with LUX_ENV=local')
})
// ── Local Node Health ───────────────────────────────────────────────
+2 -5
View File
@@ -4,7 +4,7 @@ import {
rpcCall, ethBlockNumber, ethChainId, ethGasPrice,
getCurrentValidators,
httpStatus, httpGetJson,
snap,
snap, expectBlocksSection,
} from './helpers'
const urls = getUrls()
@@ -163,10 +163,7 @@ test.describe('DeFi — Bridge', () => {
// ── Explorer — Transaction Recording ────────────────────────────────
test.describe('DeFi — Explorer Transaction Recording', () => {
test('explorer shows recent blocks', async ({ page }) => {
const res = await page.goto(urls.exploreLux, { timeout: 60_000, waitUntil: 'domcontentloaded' })
expect(res?.status()).toBe(200)
const blocksSection = page.locator('text=/block/i').first()
await expect(blocksSection).toBeVisible({ timeout: 30_000 })
await expectBlocksSection(page, urls.exploreLux, 30_000)
await snap(page, 'defi-explorer-blocks')
})
+1 -1
View File
@@ -21,7 +21,7 @@ import {
// Hanzo 36963 → pending (CTO agent deploying) — skip-gated below.
//
// These are the orgs' MAINNET chains (200200/494949/96369), pinned regardless of
// LUX_NETWORK — the DAO contracts live there, not on a per-run testnet.
// LUX_ENV — the DAO contracts live there, not on a per-run testnet.
// BountyV1 / ReputationV1 ABI selectors (keccak256(sig)[:4]).
const SEL = {
+19 -1
View File
@@ -21,7 +21,23 @@ import { httpStatus, snap } from './helpers'
const APP = 'https://app.lux.financial'
const ADMIN = 'https://admin.lux.financial'
const DOCS = 'https://docs.lux.financial'
const CREDS = { email: '[email protected]', password: 'IloveLux2026!!!' }
// Credentials come from the environment, never from this file — a password in
// a tracked file is a plaintext password however "sandbox" the account is.
// This suite's contract is skip-with-note for anything it cannot reach, so an
// unset password skips the authenticated tests rather than reddening the run;
// the dedicated sign-in proof (50-login.spec.ts) is the one that must FAIL.
const CREDS = {
email: process.env.LUX_FINANCIAL_USER || '[email protected]',
password: process.env.LUX_FINANCIAL_PASSWORD || '',
}
function haveCreds(): boolean {
if (!CREDS.password) {
console.log(' WARN: LUX_FINANCIAL_PASSWORD unset — skipping the authenticated tests')
return false
}
return true
}
// ---------------------------------------------------------------------------
// Reachability cache
@@ -59,6 +75,7 @@ async function bodyLightness(page: Page): Promise<number> {
}
async function loginCustomer(page: Page) {
test.skip(!haveCreds(), 'LUX_FINANCIAL_PASSWORD is not set')
await page.goto(`${APP}/login`, { waitUntil: 'domcontentloaded', timeout: 45_000 })
await page.locator('input[type=email]').first().fill(CREDS.email)
await page.locator('input[type=password]').first().fill(CREDS.password)
@@ -68,6 +85,7 @@ async function loginCustomer(page: Page) {
}
async function loginAdmin(page: Page) {
test.skip(!haveCreds(), 'LUX_FINANCIAL_PASSWORD is not set')
await page.goto(`${ADMIN}/login`, { waitUntil: 'domcontentloaded', timeout: 45_000 })
await page.locator('input[type=email]').first().fill(CREDS.email)
await page.locator('input[type=password]').first().fill(CREDS.password)
+212
View File
@@ -0,0 +1,212 @@
import { test, expect, Page } from '@playwright/test'
import { env, CREDS, PASSWORD_HINT, OIDC } from './env'
import { snap } from './helpers'
// ---------------------------------------------------------------------------
// The sign-in proof. [email protected] → lux.id → /dashboard, for real.
// ---------------------------------------------------------------------------
// This spec exists because every cheaper check lies:
//
// • HTTP 200 on /dashboard proves nothing — it is an SPA, so an anonymous
// visit is also 200 and stays on the /dashboard URL (measured).
// • "the password field vanished" proves nothing — the OIDC callback parks on
// an interstitial that has no form either, and /auth/callback with no code
// renders "Sign-in failed · Missing authorization code in callback URL"
// while still returning 200 (measured).
//
// So the proof is the ACCESS TOKEN. A real sign-in ends with a JWT in
// sessionStorage whose `email` claim is the account we typed and whose `iss` is
// the IdP, and IAM's userinfo endpoint accepts it. Measured 2026-07-26:
//
// GET https://lux.id/v1/iam/oauth/userinfo
// no header → 401 {"error":"invalid_token"}
// Bearer junk → 401 {"error":"invalid_token"}
// Bearer real → 200 {"sub":…,"iss":"https://lux.id","organization":"lux"}
//
// That is exactly the leg that fails in an `invalid_client` regression: the IdP
// still issues a code and the browser still lands on /dashboard, but the
// code→token exchange 401s and no token is ever stored.
//
// The password comes from LUX_E2E_PASSWORD only, never from this file, and a
// missing password FAILS the spec — a skipped login proves nothing, and silence
// is how a broken login ships.
const CLOUD = env.urls.cloud
const IDP = env.urls.id
const IDP_HOST = new URL(IDP).host
const CLOUD_HOST = new URL(CLOUD).host
// The app's IAM session, as the hanzo IAM browser SDK stores it (measured:
// sessionStorage, not localStorage, and no cookie).
const TOKEN_KEY = 'hanzo_iam_access_token'
const EXPIRES_KEY = 'hanzo_iam_expires_at'
const USERINFO = `${IDP}/v1/iam/oauth/userinfo`
// Anything here on the final page means the flow failed, however healthy the
// HTTP status looks. The first three are the owner's named failure modes; the
// last two are the real strings this deployment renders.
const FORBIDDEN = [
/sign-?in failed/i,
/invalid_client/i,
/PKCE is required/i,
/missing authorization code/i,
/signing you in/i,
]
function forbiddenHit(text: string): RegExp | undefined {
return FORBIDDEN.find((re) => re.test(text))
}
// Match a host literally. `String.replace('.', …)` only replaces the FIRST dot,
// which quietly leaves the rest as "any character" — and every local host
// (127.0.0.1:8899) has several.
function hostRe(host: string, path = ''): RegExp {
return new RegExp(host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + path)
}
async function bodyText(page: Page): Promise<string> {
return ((await page.textContent('body')) || '').replace(/\s+/g, ' ')
}
async function token(page: Page): Promise<string | null> {
return page.evaluate((k) => sessionStorage.getItem(k), TOKEN_KEY)
}
// Decode a JWT payload without a library and without ever logging the token.
function claims(jwt: string): Record<string, unknown> {
const [, payload] = jwt.split('.')
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))
}
test.describe('Login — the real thing', () => {
// ── The signal has to discriminate, or the proof below is vacuous ────────
test('anonymous /dashboard holds no session', async ({ page }) => {
await page.goto(`${CLOUD}/dashboard`, { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(4000)
const text = await bodyText(page)
await snap(page, 'login-dashboard-anon')
// 200 and still on /dashboard — this is the trap, asserted so it stays visible.
console.log(` anon landed: ${page.url()} (body ${text.length} chars)`)
expect(await token(page), 'anonymous visit must hold no access token').toBeNull()
expect(await page.locator(':text-matches("sign\\\\s*out", "i")').count(),
'anonymous visit must offer no sign-out').toBe(0)
expect(text, 'anonymous visit must offer sign-in').toMatch(/sign\s*in/i)
})
// ── The handoff must be a PUBLIC (PKCE) client ───────────────────────────
test('handoff to the IdP is a public PKCE client', async ({ page }) => {
await page.goto(`${CLOUD}/login`, { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(2500)
await snap(page, 'login-app')
// The app login page carries NO form — it hands off to the IdP by button.
await page.getByRole('button', { name: /continue with/i }).first().click()
await page.waitForURL(hostRe(IDP_HOST), { timeout: 45_000 })
await page.waitForTimeout(2000)
await snap(page, 'login-idp')
const u = new URL(page.url())
const q = u.searchParams
console.log(` authorize: ${u.origin}${u.pathname} client_id=${q.get('client_id')} `
+ `method=${q.get('code_challenge_method')}`)
expect(u.host, 'handoff must land on the IdP').toBe(IDP_HOST)
expect(q.get('client_id'), 'clientId is always <org>-<app>').toBe(OIDC.clientId)
expect(q.get('response_type')).toBe('code')
// A public client proves itself with PKCE and carries no secret.
expect(q.get('code_challenge'), 'a public client MUST send a PKCE challenge').toBeTruthy()
expect(q.get('code_challenge_method')).toBe(OIDC.challengeMethod)
expect(q.get('client_secret'), 'a browser client must never carry a secret').toBeNull()
// Browser redirect is always https://<host>/auth/callback — never /api/...
expect(q.get('redirect_uri')).toBe(`${CLOUD}${OIDC.callbackPath}`)
const text = await bodyText(page)
expect(forbiddenHit(text), `IdP page shows a failure: ${text.slice(0, 200)}`).toBeUndefined()
await expect(page.locator('input[type=password]')).toBeVisible()
})
// ── The proof ────────────────────────────────────────────────────────────
test(`${CREDS.user} signs in and reaches /dashboard with a token IAM accepts`, async ({ page }) => {
expect(CREDS.password, PASSWORD_HINT).not.toBe('')
await page.goto(`${CLOUD}/login`, { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(2500)
await page.getByRole('button', { name: /continue with/i }).first().click()
await page.waitForURL(hostRe(IDP_HOST), { 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"
// first and silently sends the run to github.com/login — measured, and it
// would have looked like a login failure rather than a bad selector.
await page.getByRole('button', { name: /^(sign in|log in)$/i }).first().click()
// Do not let a timeout be the verdict — land, then judge what we can see.
await page.waitForURL(hostRe(CLOUD_HOST, '/dashboard'), { timeout: 60_000 })
.catch(() => {})
await page.waitForTimeout(5000)
await snap(page, 'login-dashboard')
const url = new URL(page.url())
const text = await bodyText(page)
console.log(` landed: ${url.href}`)
// 1. We left the IdP and we are on the app's dashboard.
expect(url.host, `still on the IdP — sign-in did not complete (${url.href})`).toBe(CLOUD_HOST)
expect(url.pathname, `not on the dashboard (${url.href})`).toMatch(/^\/dashboard/)
// 2. No failure interstitial, whatever the HTTP status said.
expect(forbiddenHit(text) ?? forbiddenHit(url.href),
`page reports a sign-in failure: ${text.slice(0, 240)}`).toBeUndefined()
// 3. A real access token exists, and it is OUR identity from OUR IdP.
const jwt = await token(page)
expect(jwt, `no ${TOKEN_KEY} in sessionStorage — the code→token exchange failed`).toBeTruthy()
expect(jwt!.split('.'), 'access token is not a JWT').toHaveLength(3)
const c = claims(jwt!)
console.log(` token: iss=${c.iss} aud=${JSON.stringify(c.aud)} email=${c.email} sub=${c.sub}`)
expect(c.iss, 'token issued by a different issuer than the IdP we signed in at').toBe(IDP)
expect(String(c.email).toLowerCase()).toBe(CREDS.user.toLowerCase())
const expiresAt = Number(await page.evaluate((k) => sessionStorage.getItem(k), EXPIRES_KEY))
expect(expiresAt, 'stored session is already expired').toBeGreaterThan(Date.now())
// 4. IAM ACCEPTS that token. Bare and junk both 401 here, so a 200 is only
// reachable with a token the IdP really minted for this session.
// Called from INSIDE the page, the way the dashboard itself calls it.
// Never via page.request.get with an Authorization header: on a network
// error Playwright attaches the full request headers to the thrown error,
// which prints the bearer token into the run log and the HTML report.
// Observed once for real — the token never leaves the browser now.
const seen = await page.evaluate(async ({ url, key }) => {
try {
const res = await fetch(url, {
headers: { authorization: `Bearer ${sessionStorage.getItem(key)}` },
})
return { status: res.status, body: (await res.text()).slice(0, 300) }
} catch (e) {
return { status: 0, body: `fetch failed: ${(e as Error).message}` }
}
}, { url: USERINFO, key: TOKEN_KEY })
// Status first: a 502 HTML page would make JSON.parse throw and bury the
// real verdict.
expect(seen.status, `IAM did not accept the token this sign-in produced `
+ `(${seen.status}): ${seen.body}`).toBe(200)
const who = JSON.parse(seen.body) as { sub?: string; iss?: string; organization?: string }
console.log(` userinfo: ${seen.status} sub=${who.sub} org=${who.organization}`)
expect(who.sub, 'userinfo identity does not match the token').toBe(c.sub)
// 5. The UI flipped: sign-in gone, sign-out present. At 390/834 the account
// menu is collapsed, so sign-out is ATTACHED but not visible — asserting
// visibility would fail the test for a responsive layout that is correct.
const signOut = page.locator(':text-matches("sign\\\\s*out", "i")')
expect(await signOut.count(), 'authenticated dashboard offers no sign-out').toBeGreaterThan(0)
console.log(` sign-out visible at this frame: ${await signOut.first().isVisible()}`)
expect(text.replace(/sign\s*out/gi, ''), 'still offering sign-in while authenticated')
.not.toMatch(/sign\s*in\b/i)
})
})
+336
View File
@@ -0,0 +1,336 @@
// ---------------------------------------------------------------------------
// THE environment model. One axis, one definition, imported everywhere.
// ---------------------------------------------------------------------------
//
// LUX_ENV = local | devnet | testnet | mainnet (default: mainnet)
//
// Everything env-shaped — chain IDs, RPC endpoints, surface URLs, viewports,
// screenshot paths, credentials — is derived HERE and nowhere else. helpers.ts
// and playwright.config.ts are thin derivations of this module; they used to
// carry their own copies of the same tables and had already drifted apart (the
// old playwright.config.ts resolved the testnet explorer to
// `test-explore.lux.network`, which is NXDOMAIN, while helpers.ts used the real
// `explore-test.lux.network`. Measured 2026-07-26 with dig.)
//
// Chain IDs, network IDs, api host and explorer hosts are READ AT RUNTIME from
// ../NETWORKS.yaml — the declared registry — so the registry is load-bearing
// rather than decorative. Change a chain ID there and the suite follows.
//
// ONE axis, not two. The previous model complected ENV=production|local with
// LUX_NETWORK=mainnet|testnet|devnet, which made four of the six combinations
// meaningless. `LUX_NETWORK` is now rejected outright rather than silently
// half-honoured (see the guard below).
import { readFileSync } from 'fs'
import { homedir } from 'os'
import { join, resolve } from 'path'
import { load } from 'js-yaml'
export type EnvName = 'local' | 'devnet' | 'testnet' | 'mainnet'
export type NetworkKey = 'mainnet' | 'testnet' | 'devnet'
export type Org = 'lux' | 'zoo' | 'hanzo' | 'spc' | 'pars'
export const ORGS: Org[] = ['lux', 'zoo', 'hanzo', 'spc', 'pars']
export const NETWORKS: NetworkKey[] = ['mainnet', 'testnet', 'devnet']
// ── The registry (../NETWORKS.yaml) ────────────────────────────────────────
interface RegistryOrg {
explorer?: string
api?: string
network_ids?: Record<NetworkKey, number>
chains?: Record<string, { chain_ids?: Record<NetworkKey, number> }>
}
const REGISTRY_PATH = resolve(__dirname, '..', 'NETWORKS.yaml')
const REGISTRY = load(readFileSync(REGISTRY_PATH, 'utf8')) as Record<string, RegistryOrg>
// An org declares its EVM chain IDs on its one EVM chain entry (c-chain for
// Lux, <org>-evm for the app chains). Find it by shape, never by hard-coded key
// — new chains can be added to the registry without touching this file.
function chainIdsOf(org: Org): Record<NetworkKey, number> {
for (const chain of Object.values(REGISTRY[org]?.chains ?? {})) {
if (chain?.chain_ids) return chain.chain_ids
}
throw new Error(`NETWORKS.yaml: no chain_ids declared for org "${org}" (${REGISTRY_PATH})`)
}
// chainId per network per org, straight out of the registry.
export const CHAIN_IDS: Record<NetworkKey, Record<Org, number>> = (() => {
const perOrg = Object.fromEntries(ORGS.map((o) => [o, chainIdsOf(o)])) as Record<Org, Record<NetworkKey, number>>
const table = {} as Record<NetworkKey, Record<Org, number>>
for (const net of NETWORKS) {
table[net] = Object.fromEntries(ORGS.map((o) => [o, perOrg[o][net]])) as Record<Org, number>
}
return table
})()
// Lux PRIMARY-NETWORK id (1/2/3) — distinct from the C-Chain EVM chainId.
export const NETWORK_IDS: Record<NetworkKey, number> = REGISTRY.lux.network_ids!
// ── Hostname derivation ────────────────────────────────────────────────────
// Measured 2026-07-26 (dig + curl), not assumed:
// api.lux.network → 0x17871 (96369) api.lux-test.network → 0x17870 (96368)
// api.lux-dev.network → 0x1786f (96367)
// explore.lux.network / explore-test.lux.network / explore-dev.lux.network
// all resolve to 134.199.138.27; test-explore.lux.network is NXDOMAIN.
// So the suffix lands on the ORG label of an api host, and on the SERVICE label
// of an explorer host.
const SUFFIX: Record<NetworkKey, string> = { mainnet: '', testnet: '-test', devnet: '-dev' }
// api.<org>.network → api.<org><sfx>.network
export function apiHost(org: Org, network: NetworkKey): string {
const [svc, label, ...rest] = `api.${org}.network`.split('.')
return [svc, label + SUFFIX[network], ...rest].join('.')
}
// explore[-<org>].lux.network → explore[-<org>]<sfx>.lux.network
export function exploreHost(org: Org, network: NetworkKey): string {
const declared = REGISTRY[org]?.explorer
if (!declared) throw new Error(`NETWORKS.yaml: org "${org}" declares no explorer host`)
const [svc, ...rest] = declared.split('.')
return [svc + SUFFIX[network], ...rest].join('.')
}
// The one way to name an org's public API base for a given network.
export function apiBase(org: Org, network: NetworkKey): string {
return `https://${apiHost(org, network)}`
}
// ── Env resolution ─────────────────────────────────────────────────────────
if (process.env.LUX_NETWORK) {
throw new Error(
'LUX_NETWORK is retired — the suite has ONE axis now. Use LUX_ENV=local|devnet|testnet|mainnet.',
)
}
const RAW = (process.env.LUX_ENV || 'mainnet').toLowerCase()
const NAMES: EnvName[] = ['local', 'devnet', 'testnet', 'mainnet']
if (!NAMES.includes(RAW as EnvName)) {
throw new Error(`LUX_ENV="${RAW}" is not one of ${NAMES.join(' | ')}`)
}
export const ENV_NAME = RAW as EnvName
// Which column of the registry applies. `local` boots a mainnet-GENESIS network
// (`make start network=main` → 127.0.0.1:9630, chainId 96369, networkID 1), so
// it reads the mainnet column; only the endpoints move to localhost. A local
// testnet/devnet (9640/9650) is deliberately NOT a fifth env — it would give
// two axes back, and its chainId would contradict this column.
export const NETWORK_KEY: NetworkKey = ENV_NAME === 'local' ? 'mainnet' : ENV_NAME
// The registry's declared Lux api host must agree with what we derive, or the
// registry has drifted from reality — fail loudly instead of testing a fiction.
{
const declared = REGISTRY.lux.api
const derived = apiHost('lux', 'mainnet')
if (declared && declared !== derived) {
throw new Error(`NETWORKS.yaml lux.api="${declared}" but this module derives "${derived}"`)
}
}
// ── Surface URLs ───────────────────────────────────────────────────────────
// Only RPC + explorers are per-network. Every app surface (cloud, id, wallet,
// bridge, exchange, staking, mpc, safe) is a SINGLE mainnet-backed deployment
// shared by all remote envs — measured: test.lux.cloud and dev.lux.cloud both
// return 404, and lux.id is the one IdP for every env. Pretending otherwise
// would give the owner green tests against hosts that do not exist.
export interface Urls {
// Lux primary network
rpc: string
rpcInfo: string
rpcHealth: string
rpcPlatform: string
rpcXChain: string
rpcZoo: string
// Explorers + their APIs
exploreLux: string
exploreZoo: string
exploreHanzo: string
exploreSpc: string
explorePars: string
apiExploreLux: string
apiExploreZoo: string
apiExploreHanzo: string
apiExploreSpc: string
apiExplorePars: string
// Apps
exchange: string
bridge: string
staking: string
cloud: string
wallet: string
mpc: string
id: string
safe: string
}
// The one local stack: `make start network=main` for the node (:9630), and
// ./local/up.sh for the /v1 backend (hanzo cloud everything-binary, IAM
// included, :8899).
const LOCAL_RPC = 'http://127.0.0.1:9630'
const LOCAL_IAM = 'http://127.0.0.1:8899'
function remoteUrls(net: NetworkKey): Urls {
const lux = apiBase('lux', net)
return {
rpc: `${lux}/v1/bc/C/rpc`,
rpcInfo: `${lux}/v1/info`,
rpcHealth: `${lux}/v1/health`,
rpcPlatform: `${lux}/v1/bc/P`,
rpcXChain: `${lux}/v1/bc/X`,
rpcZoo: `${apiBase('zoo', net)}/v1/bc/C/rpc`,
exploreLux: `https://${exploreHost('lux', net)}`,
exploreZoo: `https://${exploreHost('zoo', net)}`,
exploreHanzo: `https://${exploreHost('hanzo', net)}`,
exploreSpc: `https://${exploreHost('spc', net)}`,
explorePars: `https://${exploreHost('pars', net)}`,
apiExploreLux: `https://api-${exploreHost('lux', net)}`,
apiExploreZoo: `https://api-${exploreHost('zoo', net)}`,
apiExploreHanzo: `https://api-${exploreHost('hanzo', net)}`,
apiExploreSpc: `https://api-${exploreHost('spc', net)}`,
apiExplorePars: `https://api-${exploreHost('pars', net)}`,
exchange: 'https://lux.exchange',
bridge: 'https://bridge.lux.network',
staking: 'https://lux.build',
cloud: 'https://lux.cloud',
wallet: 'https://wallet.lux.network',
mpc: 'https://mpc.lux.network',
id: 'https://lux.id',
safe: 'https://safe.lux.network',
}
}
function localUrls(): Urls {
return {
rpc: `${LOCAL_RPC}/v1/bc/C/rpc`,
rpcInfo: `${LOCAL_RPC}/v1/info`,
rpcHealth: `${LOCAL_RPC}/v1/health`,
rpcPlatform: `${LOCAL_RPC}/v1/bc/P`,
rpcXChain: `${LOCAL_RPC}/v1/bc/X`,
rpcZoo: `${LOCAL_RPC}/v1/bc/zoo/rpc`,
exploreLux: 'http://localhost:4010',
exploreZoo: 'http://localhost:4011',
exploreHanzo: 'http://localhost:4012',
exploreSpc: 'http://localhost:4013',
explorePars: 'http://localhost:4014',
apiExploreLux: 'http://localhost:4010',
apiExploreZoo: 'http://localhost:4011',
apiExploreHanzo: 'http://localhost:4012',
apiExploreSpc: 'http://localhost:4013',
apiExplorePars: 'http://localhost:4014',
exchange: 'http://localhost:3200',
bridge: 'http://localhost:3400',
staking: 'http://localhost:3500',
cloud: 'http://localhost:3600',
wallet: 'http://localhost:3300',
mpc: 'http://localhost:3700',
id: LOCAL_IAM,
safe: 'http://localhost:3800',
}
}
// ── OIDC contract ──────────────────────────────────────────────────────────
// Policy, one place: clientId is ALWAYS <org>-<app>, the browser redirect is
// ALWAYS https://<host>/auth/callback (never /api/...), and a browser client is
// PUBLIC — it carries a PKCE code_challenge and no secret. Measured on
// lux.cloud 2026-07-26: the handoff lands on
// lux.id/login/oauth/authorize?client_id=lux-cloud&code_challenge=…
// &code_challenge_method=S256&redirect_uri=https://lux.cloud/auth/callback
export const OIDC = {
clientId: 'lux-cloud',
callbackPath: '/auth/callback',
challengeMethod: 'S256',
} as const
// ── Concurrency the front door tolerates ───────────────────────────────────
// MEASURED, not guessed. Mainnet's api host is CDN-fronted (dig → 172.67.x) and
// served the whole gate at 4 workers with 342/342 green. Testnet resolves
// straight to the cluster LB 134.199.138.27 with no CDN in front: the same gate
// at 4 workers lost 66 cases to gateway throttling, and at 2 workers every one
// of them passed (the only remaining failures were a genuinely dead host).
// 00-health alone passes at 4 on testnet — it is AGGREGATE load that tips it, so
// the limit belongs to the environment, not to any one spec. Devnet shares that
// same LB and gets the same budget. Local has no network in the way.
export const WORKERS: Record<EnvName, number> = {
local: 4,
mainnet: 4,
testnet: 2,
devnet: 2,
}
// ── The resolved environment ───────────────────────────────────────────────
export interface Env {
name: EnvName
network: NetworkKey
networkId: number
chainIds: Record<Org, number>
urls: Urls
workers: number
isLocal: boolean
}
export const env: Env = {
name: ENV_NAME,
network: NETWORK_KEY,
networkId: NETWORK_IDS[NETWORK_KEY],
chainIds: CHAIN_IDS[NETWORK_KEY],
urls: ENV_NAME === 'local' ? localUrls() : remoteUrls(NETWORK_KEY),
workers: WORKERS[ENV_NAME],
isLocal: ENV_NAME === 'local',
}
// The one way to resolve any org's C-Chain RPC. Omit `network` for the active
// env (local-aware); pass one explicitly to reach that network's PUBLIC
// endpoint — which is what a cross-network spec wants even from a local run.
// Never hand-build /mainnet or /ext paths: every network serves flat /v1.
export function orgRpc(org: Org, network: NetworkKey = env.network): string {
if (env.isLocal && network === env.network) {
return org === 'lux' ? env.urls.rpc : `${LOCAL_RPC}/v1/bc/${org}/rpc`
}
return `${apiBase(org, network)}/v1/bc/C/rpc`
}
// ── Viewports ──────────────────────────────────────────────────────────────
// Implemented as Playwright PROJECTS, so "mobile and desktop" is the same three
// frames on every run and screenshots are comparable across runs and envs.
export interface Viewport { name: string; width: number; height: number }
export const VIEWPORTS: Viewport[] = [
{ name: 'mobile', width: 390, height: 844 }, // iPhone 15 Pro
{ name: 'tablet', width: 834, height: 1194 }, // iPad Air portrait
{ name: 'laptop', width: 1440, height: 900 }, // MacBook Air
]
// Name the frame a page is actually rendering at. Falls back to WxH so a stray
// viewport is visible in the filename rather than silently overwriting a frame.
export function viewportName(size: { width: number; height: number } | null): string {
if (!size) return 'headless'
return VIEWPORTS.find((v) => v.width === size.width && v.height === size.height)?.name
?? `${size.width}x${size.height}`
}
// ── Screenshots ────────────────────────────────────────────────────────────
// ~/Desktop/lux-e2e/<env>/<surface>-<viewport>.png — written on pass AND on
// fail. A failure you cannot look at is a failure you cannot fix.
export const SHOT_DIR = join(homedir(), 'Desktop', 'lux-e2e', ENV_NAME)
export function shotPath(surface: string, viewport: string): string {
return join(SHOT_DIR, `${surface}-${viewport}.png`)
}
// ── Credentials ────────────────────────────────────────────────────────────
// NEVER hardcoded. The password comes from the environment only; a spec that
// needs it must FAIL (not skip) when it is missing — a skipped login proves
// nothing, and silence is how a broken login ships.
export const CREDS = {
user: process.env.LUX_E2E_USER || '[email protected]',
password: process.env.LUX_E2E_PASSWORD || '',
}
export const PASSWORD_HINT =
'LUX_E2E_PASSWORD is not set — export it before running the login spec (never commit it).'
// One-line banner so every run states what it actually tested.
export function envBanner(): string {
return `LUX_ENV=${env.name} network=${env.network} networkId=${env.networkId} `
+ `chainId=${env.chainIds.lux} rpc=${env.urls.rpc}`
}
+77 -161
View File
@@ -1,179 +1,50 @@
import { Page, APIRequestContext } from '@playwright/test'
import { Page, APIRequestContext, expect } from '@playwright/test'
import { env, apiBase, CHAIN_IDS, NETWORKS, orgRpc, shotPath, viewportName } from './env'
// ---------------------------------------------------------------------------
// Environment & URL resolution
// Environment & URL resolution — DERIVED, never redeclared.
// ---------------------------------------------------------------------------
const ENV = process.env.ENV || 'production'
const NETWORK = process.env.LUX_NETWORK || 'mainnet'
// 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 }
const LOCAL_PORTS: Record<string, number> = {
mainnet: 9630,
testnet: 9640,
devnet: 9650,
}
export type LuxUrls = typeof env.urls
interface LuxUrls {
rpc: string
rpcInfo: string
rpcHealth: string
rpcPlatform: string
rpcXChain: string
rpcZoo: string
exploreLux: string
exploreZoo: string
exploreHanzo: string
exploreSpc: string
explorePars: string
apiExploreLux: string
apiExploreZoo: string
apiExploreHanzo: string
apiExploreSpc: string
apiExplorePars: string
exchange: string
bridge: string
staking: string
cloud: string
wallet: string
mpc: string
}
// Per-network API domains
export const API_DOMAINS: Record<string, string> = {
mainnet: 'https://api.lux.network',
testnet: 'https://api.lux-test.network',
devnet: 'https://api.lux-dev.network',
}
// Per-network Zoo API domains. Zoo runs its own sovereign network — its C-Chain
// RPC is served from api.zoo.network, NOT proxied under the Lux gateway.
export const ZOO_API_DOMAINS: Record<string, string> = {
mainnet: 'https://api.zoo.network',
testnet: 'https://api.zoo-test.network',
devnet: 'https://api.zoo-dev.network',
}
// Hanzo + Pars are likewise sovereign L1s serving their own C-Chain RPC from
// api.<org>.network on the canonical flat /v1/bc/C/rpc path (same shape as Zoo).
export const HANZO_API_DOMAINS: Record<string, string> = {
mainnet: 'https://api.hanzo.network',
testnet: 'https://api.hanzo-test.network',
devnet: 'https://api.hanzo-dev.network',
}
export const PARS_API_DOMAINS: Record<string, string> = {
mainnet: 'https://api.pars.network',
testnet: 'https://api.pars-test.network',
devnet: 'https://api.pars-dev.network',
}
// One canonical way to resolve any org's public C-Chain RPC for the active
// network — append the flat /v1/bc/C/rpc path to that org's API domain. Callers
// pass the org key; NEVER hand-build /mainnet or /ext paths.
const ORG_API_DOMAINS: Record<string, Record<string, string>> = {
lux: API_DOMAINS,
zoo: ZOO_API_DOMAINS,
hanzo: HANZO_API_DOMAINS,
pars: PARS_API_DOMAINS,
}
export function orgRpc(org: 'lux' | 'zoo' | 'hanzo' | 'pars', network: string = NETWORK): string {
const domains = ORG_API_DOMAINS[org]
return `${domains[network] || domains.mainnet}/v1/bc/C/rpc`
}
// Per-network explorer prefixes
const EXPLORE_DOMAINS: Record<string, string> = {
mainnet: 'https://explore.lux.network',
testnet: 'https://explore-test.lux.network',
devnet: 'https://explore-dev.lux.network',
}
const apiBase = API_DOMAINS[NETWORK] || API_DOMAINS.mainnet
const zooBase = ZOO_API_DOMAINS[NETWORK] || ZOO_API_DOMAINS.mainnet
const exploreBase = EXPLORE_DOMAINS[NETWORK] || EXPLORE_DOMAINS.mainnet
// All networks serve canonical flat /v1 paths (public RPC canonicalization):
// mainnet api.lux.network/v1/... testnet api.lux-test.network/v1/... devnet api.lux-dev.network/v1/...
const PRODUCTION_URLS: LuxUrls = {
rpc: `${apiBase}/v1/bc/C/rpc`,
rpcInfo: `${apiBase}/v1/info`,
rpcHealth: `${apiBase}/v1/health`,
rpcPlatform: `${apiBase}/v1/bc/P`,
rpcXChain: `${apiBase}/v1/bc/X`,
rpcZoo: `${zooBase}/v1/bc/C/rpc`,
exploreLux: exploreBase,
exploreZoo: exploreBase.replace('explore', 'explore-zoo'),
exploreHanzo: exploreBase.replace('explore', 'explore-hanzo'),
exploreSpc: exploreBase.replace('explore', 'explore-spc'),
explorePars: exploreBase.replace('explore', 'explore-pars'),
apiExploreLux: exploreBase.replace('explore', 'api-explore'),
apiExploreZoo: exploreBase.replace('explore', 'api-explore-zoo'),
apiExploreHanzo: exploreBase.replace('explore', 'api-explore-hanzo'),
apiExploreSpc: exploreBase.replace('explore', 'api-explore-spc'),
apiExplorePars: exploreBase.replace('explore', 'api-explore-pars'),
exchange: 'https://lux.exchange',
bridge: 'https://bridge.lux.network',
staking: 'https://lux.build',
cloud: 'https://lux.cloud',
wallet: 'https://wallet.lux.network',
mpc: 'https://mpc.lux.network',
}
function localUrls(): LuxUrls {
const port = LOCAL_PORTS[NETWORK] || 9630
return {
rpc: `http://127.0.0.1:${port}/v1/bc/C/rpc`,
rpcInfo: `http://127.0.0.1:${port}/v1/info`,
rpcHealth: `http://127.0.0.1:${port}/v1/health`,
rpcPlatform: `http://127.0.0.1:${port}/v1/bc/P`,
rpcXChain: `http://127.0.0.1:${port}/v1/bc/X`,
rpcZoo: `http://127.0.0.1:${port}/v1/bc/zoo/rpc`,
exploreLux: 'http://localhost:4010',
exploreZoo: 'http://localhost:4011',
exploreHanzo: 'http://localhost:4012',
exploreSpc: 'http://localhost:4013',
explorePars: 'http://localhost:4014',
apiExploreLux: 'http://localhost:4010',
apiExploreZoo: 'http://localhost:4011',
apiExploreHanzo: 'http://localhost:4012',
apiExploreSpc: 'http://localhost:4013',
apiExplorePars: 'http://localhost:4014',
exchange: 'http://localhost:3200',
bridge: 'http://localhost:3400',
staking: 'http://localhost:3500',
cloud: 'http://localhost:3600',
wallet: 'http://localhost:3300',
mpc: 'http://localhost:3700',
}
}
// 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/bc/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 === 'local' ? localUrls() : PRODUCTION_URLS
return env.urls
}
export function getEnv(): string {
return ENV
return env.name
}
export function getNetwork(): string {
return NETWORK
return env.network
}
export function isLocal(): boolean {
return ENV === 'local'
return env.isLocal
}
// ---------------------------------------------------------------------------
// Chain IDs
// ---------------------------------------------------------------------------
export const CHAIN_IDS = {
mainnet: { lux: 96369, zoo: 200200, hanzo: 36963, spc: 36911, pars: 494949 },
testnet: { lux: 96368, zoo: 200201, hanzo: 36962, spc: 36910, pars: 494950 },
devnet: { lux: 96367, zoo: 200202, hanzo: 36964, spc: 36912, pars: 494951 },
} as const
export function getChainIds() {
return CHAIN_IDS[NETWORK as keyof typeof CHAIN_IDS] || CHAIN_IDS.mainnet
return env.chainIds
}
// ---------------------------------------------------------------------------
@@ -321,13 +192,58 @@ export async function httpGetJson(
}
// ---------------------------------------------------------------------------
// Screenshot helper
// "The explorer is showing its blocks" — asserted ONE way.
// ---------------------------------------------------------------------------
export async function snap(page: Page, name: string) {
// 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 })
}
// ---------------------------------------------------------------------------
// 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: `e2e/${name}.png`, fullPage: false, timeout: 30_000 })
await page.screenshot({ path, fullPage: false, timeout: 30_000 })
} catch {
console.log(`WARN: Screenshot ${name} timed out, skipping`)
console.log(`WARN: screenshot ${surface} timed out, skipping`)
}
}
+56
View File
@@ -0,0 +1,56 @@
import type { Reporter, TestCase, TestResult, FullResult } from '@playwright/test/reporter'
import { copyFileSync, mkdirSync } from 'fs'
import { join } from 'path'
import { env, envBanner, SHOT_DIR, viewportName } from './env'
// ---------------------------------------------------------------------------
// Failure evidence, routed where a human will actually look at it.
// ---------------------------------------------------------------------------
// Playwright's own failure screenshots land in e2e/test-results/<hashed-dir>/,
// which nobody opens. This reporter copies each one to
//
// ~/Desktop/lux-e2e/<env>/fail-<spec>-<test>-<viewport>.png
//
// alongside the deliberate snap() shots, so pass OR fail, the run leaves behind
// something you can look at. Two orthogonal concerns, no overlap: snap() is the
// frame a spec MEANT to capture, this is the state a test DIED in.
//
// It also prints the env banner at both ends of the run, so a log excerpt can
// never be mistaken for a different environment's result.
function slug(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 70)
}
export default class ShotReporter implements Reporter {
private failures = 0
onBegin() {
mkdirSync(SHOT_DIR, { recursive: true })
console.log(`\n▶ ${envBanner()}`)
console.log(` shots → ${SHOT_DIR}\n`)
}
onTestEnd(test: TestCase, result: TestResult) {
if (result.status === 'passed' || result.status === 'skipped') return
this.failures++
const shot = result.attachments.find((a) => a.name === 'screenshot' && a.path)
if (!shot?.path) return
// Project name IS the viewport name (see playwright.config.ts projects).
const vp = test.parent.project()?.name
?? viewportName(null)
const spec = test.location.file.split('/').pop()?.replace(/\.spec\.ts$/, '') ?? 'spec'
const name = `fail-${slug(spec)}-${slug(test.title)}-${vp}.png`
try {
mkdirSync(SHOT_DIR, { recursive: true })
copyFileSync(shot.path, join(SHOT_DIR, name))
console.log(` ✗ evidence → ${join(SHOT_DIR, name)}`)
} catch (e) {
console.log(` ! could not copy failure screenshot: ${(e as Error).message}`)
}
}
onEnd(result: FullResult) {
console.log(`\n◀ ${result.status} — LUX_ENV=${env.name}, ${this.failures} failing`)
console.log(` shots → ${SHOT_DIR}\n`)
}
}
+10 -10
View File
@@ -2,7 +2,7 @@
"name": "@luxfi/universe",
"version": "1.0.2",
"private": true,
"description": "Lux Universe — multi-chain infrastructure with full E2E testing",
"description": "Lux Universe \u2014 multi-chain infrastructure with full E2E testing",
"scripts": {
"test": "npx playwright test",
"test:health": "npx playwright test e2e/00-health.spec.ts",
@@ -37,17 +37,17 @@
"test:full": "npx playwright test e2e/00-health.spec.ts e2e/01-explorer.spec.ts e2e/02-exchange.spec.ts e2e/03-bridge.spec.ts e2e/04-staking.spec.ts e2e/05-cloud.spec.ts e2e/06-wallet.spec.ts e2e/07-validator.spec.ts e2e/08-validator-lifecycle.spec.ts e2e/09-defi-flow.spec.ts e2e/10-stats-dashboard.spec.ts e2e/11-wallet-ops.spec.ts e2e/12-staking-rpc.spec.ts e2e/13-bridge-mpc.spec.ts e2e/14-dex-contracts.spec.ts e2e/15-nft-marketplace.spec.ts e2e/16-app-chains.spec.ts e2e/17-governance.spec.ts e2e/18-broker.spec.ts e2e/19-broker-deep.spec.ts e2e/30-dao-onchain.spec.ts e2e/31-vote-surfaces.spec.ts e2e/32-work-boards.spec.ts e2e/33-bounty-bot.spec.ts e2e/34-pars-chain.spec.ts",
"test:headed": "npx playwright test --headed",
"test:ui": "npx playwright test --ui",
"test:prod": "ENV=production npx playwright test",
"test:local": "ENV=local npx playwright test",
"test:mainnet": "LUX_NETWORK=mainnet npx playwright test",
"test:testnet": "LUX_NETWORK=testnet npx playwright test",
"test:devnet": "LUX_NETWORK=devnet npx playwright test",
"test:local": "LUX_ENV=local npx playwright test",
"test:mainnet": "LUX_ENV=mainnet npx playwright test",
"test:testnet": "LUX_ENV=testnet npx playwright test",
"test:devnet": "LUX_ENV=devnet npx playwright test",
"test:gate": "npx playwright test e2e/00-health.spec.ts e2e/08-validator-lifecycle.spec.ts e2e/09-defi-flow.spec.ts e2e/13-bridge-mpc.spec.ts e2e/14-dex-contracts.spec.ts --reporter=list",
"test:gate:mainnet": "LUX_NETWORK=mainnet npm run test:gate",
"test:gate:testnet": "LUX_NETWORK=testnet npm run test:gate",
"test:gate:devnet": "LUX_NETWORK=devnet npm run test:gate",
"test:gate:mainnet": "LUX_ENV=mainnet npm run test:gate",
"test:gate:testnet": "LUX_ENV=testnet npm run test:gate",
"test:gate:devnet": "LUX_ENV=devnet npm run test:gate",
"e2e:install": "npx playwright install --with-deps chromium",
"e2e:report": "npx playwright show-report"
"e2e:report": "npx playwright show-report",
"test:login": "npx playwright test e2e/50-login.spec.ts"
},
"devDependencies": {
"@playwright/test": "^1.52.0",
+60 -108
View File
@@ -1,133 +1,85 @@
import { defineConfig, devices } from '@playwright/test'
import { defineConfig } from '@playwright/test'
import { env, envBanner, VIEWPORTS, SHOT_DIR } from './e2e/env'
// ---------------------------------------------------------------------------
// Environment detection
// The multi-environment e2e suite.
// ---------------------------------------------------------------------------
const ENV = process.env.ENV || 'production'
const NETWORK = process.env.LUX_NETWORK || 'mainnet'
// Per-network local RPC ports
const LOCAL_PORTS: Record<string, number> = {
mainnet: 9630,
testnet: 9640,
devnet: 9650,
}
// ---------------------------------------------------------------------------
// URL maps per environment
// ---------------------------------------------------------------------------
// Network-specific RPC base URLs
// Each network has its own domain: lux.network, lux-test.network, lux-dev.network
const RPC_BASE: Record<string, string> = {
mainnet: 'https://api.lux.network',
testnet: 'https://api.lux-test.network',
devnet: 'https://api.lux-dev.network',
}
const rpcBase = RPC_BASE[NETWORK] || RPC_BASE.mainnet
// All networks serve canonical flat /v1 paths (public RPC canonicalization):
// mainnet api.lux.network/v1/... testnet api.lux-test.network/v1/... devnet api.lux-dev.network/v1/...
// Network-specific explorer subdomains
const EXPLORE_PREFIX: Record<string, string> = {
mainnet: '',
testnet: 'test-',
devnet: 'dev-',
}
const ep = EXPLORE_PREFIX[NETWORK] || ''
const urls: Record<string, Record<string, string>> = {
production: {
// RPC (network-aware)
rpc: `${rpcBase}/v1/bc/C/rpc`,
rpcInfo: `${rpcBase}/v1/info`,
rpcHealth: `${rpcBase}/v1/health`,
rpcPlatform: `${rpcBase}/v1/bc/P`,
rpcXChain: `${rpcBase}/v1/bc/X`,
rpcZoo: `${rpcBase}/v1/bc/zoo/rpc`,
// Explorers (network-aware)
exploreLux: `https://${ep}explore.lux.network`,
exploreZoo: `https://${ep}explore-zoo.lux.network`,
exploreHanzo: `https://${ep}explore-hanzo.lux.network`,
exploreSpc: `https://${ep}explore-spc.lux.network`,
explorePars: `https://${ep}explore-pars.lux.network`,
// Explorer APIs
apiExploreLux: `https://api-${ep}explore.lux.network`,
apiExploreZoo: `https://api-${ep}explore-zoo.lux.network`,
apiExploreHanzo: `https://api-${ep}explore-hanzo.lux.network`,
apiExploreSpc: `https://api-${ep}explore-spc.lux.network`,
apiExplorePars: `https://api-${ep}explore-pars.lux.network`,
// Apps (shared across networks)
exchange: 'https://lux.exchange',
bridge: 'https://bridge.lux.network',
staking: 'https://lux.build',
cloud: 'https://cloud.lux.network',
wallet: 'https://wallet.lux.network',
mpc: 'https://mpc.lux.network',
},
local: {
rpc: `http://127.0.0.1:${LOCAL_PORTS[NETWORK]}/v1/bc/C/rpc`,
rpcInfo: `http://127.0.0.1:${LOCAL_PORTS[NETWORK]}/v1/info`,
rpcHealth: `http://127.0.0.1:${LOCAL_PORTS[NETWORK]}/v1/health`,
rpcPlatform: `http://127.0.0.1:${LOCAL_PORTS[NETWORK]}/v1/bc/P`,
rpcZoo: `http://127.0.0.1:${LOCAL_PORTS[NETWORK]}/v1/bc/zoo/rpc`,
exploreLux: 'http://localhost:4010',
exploreZoo: 'http://localhost:4011',
exploreHanzo: 'http://localhost:4012',
exploreSpc: 'http://localhost:4013',
explorePars: 'http://localhost:4014',
apiExploreLux: 'http://localhost:4010',
apiExploreZoo: 'http://localhost:4011',
apiExploreHanzo: 'http://localhost:4012',
apiExploreSpc: 'http://localhost:4013',
apiExplorePars: 'http://localhost:4014',
exchange: 'http://localhost:3200',
bridge: 'http://localhost:3400',
staking: 'http://localhost:3500',
cloud: 'http://localhost:3600',
wallet: 'http://localhost:3300',
mpc: 'http://localhost:3700',
},
}
// LUX_ENV=local | devnet | testnet | mainnet (default: mainnet)
//
// make e2e # mainnet, all three viewports
// make e2e-testnet # same specs, testnet endpoints
// make e2e-login # just the sign-in proof
// npx playwright test --project=mobile
//
// Every env-shaped value comes from e2e/env.ts, which reads NETWORKS.yaml — this
// config declares NO urls, ports or chain IDs of its own. It used to keep a
// third copy of those tables (and resolved the testnet explorer to the NXDOMAIN
// host test-explore.lux.network).
//
// PROJECTS ARE VIEWPORTS. "Mobile and desktop" therefore means the same three
// frames on every run: mobile 390x844, tablet 834x1194, laptop 1440x900. Run one
// with --project=<name>; screenshots carry the frame name so they never collide.
//
// The branded web3-surface matrix keeps its own config (playwright.branded.
// config.ts) with its own 4-breakpoint grid and matrix reporter — one way to run
// each suite, so this one testIgnores it.
export default defineConfig({
testDir: './e2e',
// The branded web3-surface matrix runs under its own dedicated config
// (playwright.branded.config.ts): 4-breakpoint projects + a matrix reporter +
// real Chrome channel. Keep the two suites independent — one way to run each.
testIgnore: '**/branded/**',
// File-level parallelism only (fullyParallel:false keeps tests inside a file
// serial, which is where the ordering assumptions live). The worker count is
// per-environment and measured — see WORKERS in e2e/env.ts. On mainnet 1
// worker and 4 gave an IDENTICAL result set (154 passed / 2 failed) in 5.9m vs
// 2.3m; testnet throttles at 4 and is clean at 2. Everything but the login
// spec is read-only against live surfaces, and rpcCall() backs off on 429.
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: process.env.CI
? [['github'], ['html', { open: 'never' }], ['junit', { outputFile: 'e2e/results.xml' }]]
: [['list'], ['html', { open: 'never' }]],
// One retry locally, so a page that was slow to render under 4-worker load is
// reported as FLAKY rather than either failing the run or being silently
// green. Measured: with 4 workers, `09-defi-flow › explorer shows recent
// blocks` failed once at laptop and passed on its own in 13s. Flaky is a
// status, not a pass — a genuinely broken surface still fails twice and goes
// red. Never raise this to paper over a real failure.
retries: process.env.CI ? 2 : 1,
workers: env.workers,
timeout: 120_000,
expect: { timeout: 15_000 },
reporter: [
// Relocates every FAILURE's screenshot to SHOT_DIR so a red run is
// immediately viewable next to the deliberate snap() shots.
['./e2e/shot-reporter.ts'],
...(process.env.CI
? [['github'] as const, ['junit', { outputFile: 'e2e/results.xml' }] as const]
: [['list'] as const]),
['html', { open: 'never' }] as const,
],
use: {
baseURL: urls[ENV]?.exploreLux || urls.production.exploreLux,
baseURL: env.urls.exploreLux,
trace: 'on-first-retry',
screenshot: 'on',
screenshot: 'only-on-failure',
video: 'on-first-retry',
actionTimeout: 15_000,
navigationTimeout: 60_000,
ignoreHTTPSErrors: true,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
projects: VIEWPORTS.map((v) => ({
name: v.name,
use: { viewport: { width: v.width, height: v.height } },
})),
outputDir: './e2e/test-results',
metadata: {
env: ENV,
network: NETWORK,
urls: urls[ENV] || urls.production,
banner: envBanner(),
env: env.name,
network: env.network,
networkId: env.networkId,
chainIds: env.chainIds,
workers: env.workers,
shots: SHOT_DIR,
urls: env.urls,
},
})