CI/CD / render (push) Waiting to run
The primary-network chains answer to either case; one spelling beats two. /v1/chain/c/rpc, /v1/chain/p, /v1/chain/x across the estate. Co-authored-by: Hanzo AI <[email protected]>
473 lines
23 KiB
TypeScript
473 lines
23 KiB
TypeScript
// ---------------------------------------------------------------------------
|
||
// 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 { createHash } from 'crypto'
|
||
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
|
||
// The SAME exchange deployment, addressed by its cluster hostname instead of
|
||
// its vanity domain. Not a second product — a second door to one product.
|
||
// MEASURED 2026-07-26: `https://lux.exchange` fails the TLS handshake from
|
||
// this network in all three stacks (curl → `tlsv1 alert protocol version`,
|
||
// python/openssl → same, chromium → net::ERR_SSL_PROTOCOL_ERROR) while
|
||
// `https://exchange.lux.network` serves the identical app 200 with a 4,589-
|
||
// char body on every one of the 22 routes probed. Hard-coding only the
|
||
// vanity host is why 02-exchange went red and 20-exchange-deep skipped all
|
||
// 36 of its cases — 49 routes of a shipped product tested by nothing.
|
||
// exchangeBase() in helpers.ts picks the reachable door and SAYS which.
|
||
exchangeOrigin: string
|
||
bridge: string
|
||
staking: string
|
||
cloud: string
|
||
wallet: string
|
||
mpc: string
|
||
id: string
|
||
safe: string
|
||
// Operator surfaces. Measured 2026-07-26: superadmin.lux.cloud → 200
|
||
// "Lux Admin" with /login /dashboard /users /orgs /settings all served;
|
||
// admin.lux.cloud → 401 "authentication required" (an ingress DOES exist —
|
||
// the earlier "404, no ingress" reading was of a different cluster).
|
||
admin: string
|
||
superadmin: 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/chain/c/rpc`,
|
||
rpcInfo: `${lux}/v1/info`,
|
||
rpcHealth: `${lux}/v1/health`,
|
||
rpcPlatform: `${lux}/v1/chain/p`,
|
||
rpcXChain: `${lux}/v1/chain/x`,
|
||
rpcZoo: `${apiBase('zoo', net)}/v1/chain/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',
|
||
exchangeOrigin: 'https://exchange.lux.network',
|
||
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',
|
||
admin: 'https://admin.lux.cloud',
|
||
superadmin: 'https://superadmin.lux.cloud',
|
||
}
|
||
}
|
||
|
||
function localUrls(): Urls {
|
||
return {
|
||
rpc: `${LOCAL_RPC}/v1/chain/c/rpc`,
|
||
rpcInfo: `${LOCAL_RPC}/v1/info`,
|
||
rpcHealth: `${LOCAL_RPC}/v1/health`,
|
||
rpcPlatform: `${LOCAL_RPC}/v1/chain/p`,
|
||
rpcXChain: `${LOCAL_RPC}/v1/chain/x`,
|
||
rpcZoo: `${LOCAL_RPC}/v1/chain/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',
|
||
exchangeOrigin: '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',
|
||
admin: 'http://localhost:3600',
|
||
superadmin: 'http://localhost:3600',
|
||
}
|
||
}
|
||
|
||
// ── 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',
|
||
// IAM routes ONE authorize path (iam internal/oidc/oidc.go PathAuthorize).
|
||
authorizePath: '/v1/iam/oauth/authorize',
|
||
tokenPath: '/v1/iam/oauth/token',
|
||
jwksPath: '/v1/iam/.well-known/jwks',
|
||
} as const
|
||
|
||
// The one way to build an IAM authorize URL. Hand-building `${iam}/oauth/
|
||
// authorize` or `/oauth2/authorize` is the trap: the IdP SPA answers ANY
|
||
// unrouted path with 200 text/html, so a hand-built URL fails SILENTLY and a
|
||
// test that merely asserts a 2xx/3xx passes on a dead endpoint.
|
||
//
|
||
// Measured on lux.id 2026-07-27 (same query on each):
|
||
// /v1/iam/oauth/authorize 400 text/plain "authorization error: invalid redirect_uri" <- REAL
|
||
// /oauth/authorize 200 text/html (SPA shell)
|
||
// /oauth2/authorize 200 text/html (SPA shell)
|
||
// /zzz-control 200 text/html (SPA shell) <- control: 200 proves nothing
|
||
export function authorizeUrl(iamBase: string, params: Record<string, string>): string {
|
||
return `${iamBase.replace(/\/$/, '')}${OIDC.authorizePath}?${new URLSearchParams(params)}`
|
||
}
|
||
|
||
// A PKCE pair for a PUBLIC client, which lux-cloud is. IAM refuses an authorize
|
||
// request without one — measured 2026-08-08, the request lands back on the
|
||
// caller as
|
||
// /auth/callback?error=invalid_request
|
||
// &error_description=PKCE+is+required+for+public+clients
|
||
// which is a REDIRECT, so `isRealAuthorizeResponse` rightly calls it real and
|
||
// the test then fails on the missing login form instead. The endpoint was never
|
||
// the problem; the request was incomplete.
|
||
//
|
||
// The verifier is fixed rather than random: a test that builds a fresh secret
|
||
// each run cannot be re-run against the same authorize code, and nothing here
|
||
// exchanges it — the challenge only has to be well-formed at authorize time.
|
||
export function pkce(): { verifier: string; challenge: string; method: string } {
|
||
const verifier = 'lux-e2e-pkce-verifier-0000000000000000000000000000'
|
||
const challenge = createHash('sha256').update(verifier).digest('base64url')
|
||
return { verifier, challenge, method: OIDC.challengeMethod }
|
||
}
|
||
|
||
// An authorize response is REAL only when it is not the SPA catch-all: either a
|
||
// redirect to the login page, or a text/plain OAuth error from the endpoint
|
||
// itself. A 200 text/html is the shell — always a failure.
|
||
export function isRealAuthorizeResponse(status: number, contentType: string): boolean {
|
||
if (status >= 300 && status < 400) return true // redirect to the login page
|
||
return !/text\/html/i.test(contentType) // anything else must not be the shell
|
||
}
|
||
|
||
// ── 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,
|
||
}
|
||
|
||
// ── Liveness: what "the chain is alive" MEANS ──────────────────────────────
|
||
// The single biggest hole in this suite was that nothing asserted the chain was
|
||
// moving. Every liveness test reduced to `eth_blockNumber > 0`, which a chain
|
||
// frozen for two and a half days answers perfectly — mainnet has been stuck at
|
||
// 1,098,191 for 58.9 hours and the whole suite was green.
|
||
//
|
||
// Readiness is worthless here: all 15 pods read 5/5 Ready and every public API
|
||
// returned 200 on all three networks while one of them had not produced a block
|
||
// since Thursday. Gate on the TIP, never on readiness.
|
||
//
|
||
// A fixed "block every N seconds" threshold is also wrong — these chains are
|
||
// DEMAND-DRIVEN, so an idle one legitimately produces nothing until something
|
||
// asks. MEASURED inter-block gaps over the last 8 blocks, 2026-07-26:
|
||
//
|
||
// mainnet 27375, 100, 9, 2, 26, 5, 2, 2 → median 7s (then it stopped)
|
||
// testnet 290, 290, 327, 258, 320, 322, 288, 312 → median ~300s
|
||
// devnet 605, 300, 290, 305, 255, 345, 300, 290 → median ~300s
|
||
//
|
||
// Testnet and devnet sit at a 5-minute cadence because a heartbeat CronJob
|
||
// writes to them; mainnet has no heartbeat and produced at seconds-scale while
|
||
// there was demand. One constant cannot serve both — so the gate CALIBRATES
|
||
// ITSELF: the tip may be no older than STALE_GAP_MULTIPLE × the median recent
|
||
// gap, floored at STALE_FLOOR_SEC so a fast chain is not failed by one slow
|
||
// CDN round trip. Applied to the numbers above:
|
||
//
|
||
// mainnet max(4×7, 600) = 600s vs tip age 211,947s → RED (correct)
|
||
// testnet max(4×300, 600) = 1200s vs tip age 218s → green
|
||
// devnet max(4×300, 600) = 1200s vs tip age 198s → green
|
||
//
|
||
// Never raise these to get mainnet green. The freeze is the finding.
|
||
export const LIVENESS = {
|
||
/** How many recent blocks to sample when deriving the chain's own cadence. */
|
||
gapSamples: 8,
|
||
/** Tip may be this many median-gaps old before the chain counts as stalled. */
|
||
staleGapMultiple: 4,
|
||
/** Floor for the derived budget, so a 2s-block chain tolerates a slow hop. */
|
||
staleFloorSec: 600,
|
||
/** Window for the monotonicity/reorg sample. Shorter than any cadence above:
|
||
* it is deliberately NOT an advancement test (see 00-liveness.spec.ts). */
|
||
reorgWindowSec: 20,
|
||
} as const
|
||
|
||
// ── 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',
|
||
}
|
||
|
||
// ── Indexed chains ─────────────────────────────────────────────────────────
|
||
// Which chains have a live indexer, asked and answered ONCE. Four specs used
|
||
// to carry their own copy of this list, so one wrong entry was four red tests.
|
||
//
|
||
// SPC is deliberately absent. There is no SPC chain to index anywhere, and the
|
||
// explorer says so itself: k8s/lux-mainnet/explorer.yaml sets `enabled: false`
|
||
// on slug `spc`, so the supervisor never mounts /v1/indexer/spc and
|
||
// api-explore-spc.lux.network answers `404 page not found` in text/plain where
|
||
// a stats document belongs. Restore the entry the day an SPC L1 exists and the
|
||
// indexer is enabled — the chain stays in REGISTRY either way, because it is a
|
||
// declared network; this list is about what is running, not what is planned.
|
||
export interface Explorer { org: Org; name: string; api: string; ui: string }
|
||
|
||
export const EXPLORERS: Explorer[] = [
|
||
{ org: 'lux', name: 'Lux C-Chain', api: env.urls.apiExploreLux, ui: env.urls.exploreLux },
|
||
{ org: 'zoo', name: 'Zoo', api: env.urls.apiExploreZoo, ui: env.urls.exploreZoo },
|
||
{ org: 'hanzo', name: 'Hanzo', api: env.urls.apiExploreHanzo, ui: env.urls.exploreHanzo },
|
||
{ org: 'pars', name: 'Pars', api: env.urls.apiExplorePars, ui: env.urls.explorePars },
|
||
]
|
||
|
||
// 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/chain/${org}/rpc`
|
||
}
|
||
return `${apiBase(org, network)}/v1/chain/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/<set>/<surface>-<viewport>.png — written on pass AND on
|
||
// fail. A failure you cannot look at is a failure you cannot fix.
|
||
//
|
||
// The SET is the env name by default, so the matrix runs never collide. A
|
||
// deliberate one-off capture names its own set instead (LUX_SHOT_SET=
|
||
// institutional-baseline) and reuses this exact path machinery — a second
|
||
// screenshot helper is how two runs end up with two naming schemes.
|
||
export const SHOT_SET = process.env.LUX_SHOT_SET || ENV_NAME
|
||
export const SHOT_DIR = join(homedir(), 'Desktop', 'lux-e2e', SHOT_SET)
|
||
|
||
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}`
|
||
}
|