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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user