The suite was green while mainnet had not produced a block in 59 hours.
Nothing in it asked whether the chain was alive. `00-health` has nine tests
that all reduce to `eth_blockNumber > 0` — "node reports healthy", "C-Chain is
bootstrapped", "all primary chains bootstrapped" — and a frozen chain answers
every one of them. Readiness agreed: 15 pods at 5/5 Ready, every public API
200, on all three networks, one of which was stopped.
mainnet 96369 tip 1,098,191 age 58.9h testnet 96368 tip 1,354 age 3.6m
devnet 96367 tip 528 age 3.3m
e2e/00-liveness.spec.ts gates on the TIP. A fixed "block every N seconds" is
wrong for demand-driven chains, so the gate CALIBRATES ITSELF: the tip may be
no older than 4x the chain's own median gap over its last 8 blocks, floored at
600s. Measured cadences are 7s on mainnet and ~300s on testnet/devnet (the
heartbeat CronJob) — no constant spans both. Median, not mean: one 27,375s gap
IS the freeze, and a mean would let a stall widen its own budget.
Four orthogonal tests: fresh, monotonic (the tip never goes backwards and a
served block keeps its hash — the reorg shape devnet had at height 512),
consistent (net_version vs NETWORKS.yaml), and honest — a full validator set
must imply a fresh tip, which asserts the readiness lie head-on. Mainnet is RED
on three of them. That is the correct result; do not widen the budget.
Coverage was a function of which spec somebody happened to write: Safe's 31
routes, MPC's 15 and the whole admin surface had ZERO tests between them while
api.lux.financial, whose API is dead, carried 293. So routes become DATA —
e2e/routes.ts, 156 declared pages across 11 surfaces, iterated by 60-surfaces
(anonymous) and 61-console (signed in). A new page is one line there.
HTTP status is not evidence: SEVEN of the nine origins answer 200 for a path
that does not exist (measured with a /zzz-bogus control each; only Safe and the
explorer 404 honestly, and those two get a live control test every run). Every
route is therefore opened in a browser and must have PAINTED — real text into a
non-empty root, AND not the app's error boundary. Two assertions, because a
crash screen sails through a length check: exchange /pool is 200 with 553
characters that all say "Something went wrong". One reload before failing,
which is exactly the line between a page with no retry and a broken page.
First run found, each with a screenshot:
exchange /positions /pool /pool/v2 /bridge /teleport crash screen at 200
exchange /vote white screen, 0 chars
explorer /tokens /stats /advanced-filter crash screen at 200
console /dashboard/{safes,mpc,bridge,dex,deploy} no network, chainId,
address or height anywhere on screen — five of the nine surfaces the owner
named, and nothing on them tells an operator which chain they are on
Also fixed, all of them tests that could not fail:
- Peer connectivity was `catch { console.log('firewalled — PASS'); return }`.
/v1/info is not uniformly firewalled, it is inconsistently deployed (POST:
testnet 200, mainnet 404, devnet 404). Now asserts mesh depth wherever it is
served and falls back to the P-Chain validator set, which a single-node
network fails.
- 02-exchange and 20-exchange-deep skipped 58 cases behind lux.exchange, whose
TLS handshake fails from this network in curl, python and chromium alike.
exchange.lux.network is the same deployment at 200 on all 22 routes.
helpers.exchangeBase() picks the door that answers; the vanity domain being
down is now one deliberate assertion instead of a silence over 22 pages.
- 13-iam asserted a hand-built /.well-known/jwks that returns 200 text/html —
the SPA catch-all — so JSON.parse threw. Follows the discovery document now,
which declares /v1/iam/.well-known/jwks, and checks the content-type.
- 50-login sliced userinfo to 300 chars and then parsed it, so a real sign-in
failed on "Unterminated string in JSON" AFTER proving IAM accepted the token.
Parsed in-page now, three claims out, nothing else in the log.
Login proven end to end: [email protected] -> lux.id -> /dashboard, iss=lux.id,
aud=lux-cloud-org-lux, userinfo 200, sign-out present. 3/3.
Read-only throughout. Every screen in 61-console can move value — treasury,
safes, mpc, bridge. It navigates and screenshots. No form submitted, nothing
prepared, nothing signed, nothing broadcast, nothing re-keyed.
Co-authored-by: Hanzo Dev <[email protected]>
155 lines
8.1 KiB
TypeScript
155 lines
8.1 KiB
TypeScript
import { test, expect, APIRequestContext } from '@playwright/test'
|
|
import { env } from './env'
|
|
import { NOT_SERVED, ROUTE_COUNT, SURFACES } from './routes'
|
|
import { exchangeBase, expectRendered, httpStatus, renderRoute, snap } from './helpers'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// EVERY PAGE OF EVERY PRODUCT, anonymously — the coverage floor
|
|
// ---------------------------------------------------------------------------
|
|
// Before this file the suite tested 8 of lux.cloud's 82 pages, 7 of the
|
|
// explorer's, 0 of Safe's 31, 1 of MPC's 15, 0 of the admin surface, and 0 of
|
|
// the exchange's 22 (all skipped behind an unreachable vanity host). Whole
|
|
// shipped products had no test that would notice if they stopped rendering.
|
|
//
|
|
// The routes live in e2e/routes.ts as DATA, so this file is one loop, and
|
|
// adding a page to a product is one line there rather than a new spec here.
|
|
//
|
|
// WHAT IT ASSERTS, and why it is not a status-code sweep: SEVEN of the nine
|
|
// origins answer 200 for a path that does not exist (measured with a /zzz-bogus
|
|
// control against each — see e2e/routes.ts). A green status sweep across them
|
|
// would be worth exactly nothing. So every route is opened in a real browser at
|
|
// the project's viewport and has to have PAINTED: real text into a non-empty
|
|
// root, and not the app's own error boundary. Two separate assertions, because
|
|
// a crash screen sails through a length check — exchange /pool serves HTTP 200
|
|
// with 553 characters, all of which say "Something went wrong".
|
|
//
|
|
// Between them they catch both live failure shapes: the white screen behind a
|
|
// 200 (exchange /vote — 0 chars, 0 root children, every load) and the crash
|
|
// screen behind a 200 (exchange /pool, /positions, /bridge, /teleport).
|
|
//
|
|
// Protected routes are asserted as protected. The MPC console's 13 signed-in
|
|
// screens — including /bridge/fees, the owner's "manage bridge fees" — must
|
|
// bounce an anonymous browser to the login screen. Serving them would be the
|
|
// finding, so the redirect IS the pass.
|
|
//
|
|
// Read-only throughout. It opens pages and looks at them. No form is submitted,
|
|
// nothing is signed, nothing is broadcast.
|
|
|
|
test.describe.configure({ mode: 'parallel' })
|
|
|
|
console.log(` route registry: ${ROUTE_COUNT} declared pages across ${SURFACES.length} surfaces `
|
|
+ `— LUX_ENV=${env.name}`)
|
|
|
|
for (const s of SURFACES) {
|
|
test.describe(`${s.label}`, () => {
|
|
// The exchange is reached through whichever of its two hostnames answers
|
|
// from this network — resolved once, in helpers, and logged.
|
|
async function base(request: APIRequestContext): Promise<string> {
|
|
return s.id === 'exchange' ? await exchangeBase(request) : s.base
|
|
}
|
|
|
|
for (const route of s.routes) {
|
|
const name = route === '/' ? 'home' : route.replace(/^\//, '').replace(/\//g, '-')
|
|
|
|
if (s.auth === 'login-redirect') {
|
|
test(`${route} is protected`, async ({ page, request }) => {
|
|
const url = (await base(request)) + route
|
|
const r = await renderRoute(page, url)
|
|
const landed = new URL(r.url)
|
|
console.log(` ${route} → ${landed.pathname} (HTTP ${r.status}, ${r.textLen} chars)`)
|
|
|
|
// POSITIVE signal: the browser ended up on a sign-in screen. Either
|
|
// the URL is the login path, or the page is showing a sign-in form.
|
|
const onLogin = /login|signin|sign-in|auth/i.test(landed.pathname + landed.search)
|
|
const hasSignIn = await page
|
|
.locator('input[type=password], button:has-text("Sign in"), button:has-text("Log in"), a:has-text("Sign in")')
|
|
.first().isVisible({ timeout: 5_000 }).catch(() => false)
|
|
|
|
expect(onLogin || hasSignIn,
|
|
`${url} served an anonymous browser a page that is neither a login screen nor a `
|
|
+ `redirect to one — it landed on ${r.url} with ${r.textLen} chars of content. `
|
|
+ `This surface signs transactions.`).toBe(true)
|
|
})
|
|
continue
|
|
}
|
|
|
|
test(`${route} renders`, async ({ page, request }) => {
|
|
const url = (await base(request)) + route
|
|
const r = await expectRendered(page, url, `${s.label} ${route}`)
|
|
console.log(` ${route.padEnd(34)} HTTP ${String(r.status).padEnd(4)} `
|
|
+ `${String(r.textLen).padStart(6)} chars ${r.heading ? `“${r.heading}”` : '(no heading)'}`)
|
|
// Screenshot on PASS as well as fail — the fail path is shot-reporter's.
|
|
await snap(page, `${s.id}-${name}`)
|
|
})
|
|
}
|
|
|
|
// A surface that answers 200 for everything cannot tell you a route is
|
|
// gone, so the ones that DO 404 honestly get that verified once. It is the
|
|
// control that makes the rest of this file's status logging meaningful.
|
|
if (!s.catchAll) {
|
|
test('unknown route 404s (the status codes here mean something)', async ({ request }) => {
|
|
const url = s.base + '/zzz-not-a-real-route-' + Date.now()
|
|
const status = await httpStatus(request, url)
|
|
console.log(` control: ${url} → ${status}`)
|
|
expect(status,
|
|
`${s.label} answers ${status} for a path that does not exist, so no status code from `
|
|
+ `this origin is evidence of anything. Mark it catchAll in e2e/routes.ts.`).toBe(404)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// ── Routes this deployment does not serve ──────────────────────────────────
|
|
// Not a failure — a RECORD. The explorer is a Blockscout build with several
|
|
// upstream pages switched off, and lux.build gates /builds behind auth. They
|
|
// are re-measured every run so that the day one of them starts serving, the log
|
|
// says so and it can be moved into the registry deliberately.
|
|
test.describe('Declared upstream but not served here', () => {
|
|
for (const [id, routes] of Object.entries(NOT_SERVED)) {
|
|
test(`${id}: ${routes.length} route(s) still absent`, async ({ request }) => {
|
|
const s = SURFACES.find((x) => x.id === id)!
|
|
const live: string[] = []
|
|
for (const route of routes) {
|
|
const status = await httpStatus(request, s.base + route)
|
|
console.log(` ${route.padEnd(20)} ${status}`)
|
|
if (status === 200) live.push(route)
|
|
}
|
|
if (live.length) {
|
|
console.log(` NOTE: ${live.join(', ')} now serve(s) — move into SURFACES in e2e/routes.ts`)
|
|
}
|
|
// Nothing is asserted about absent pages. What IS asserted is that the
|
|
// origin is up, so "all 404" never silently means "the host is gone".
|
|
expect(await httpStatus(request, s.base),
|
|
`${s.base} itself is unreachable — the 404s above are not about routes`).toBeGreaterThan(0)
|
|
})
|
|
}
|
|
})
|
|
|
|
// ── The one surface with no route list, because it has no /v1 ──────────────
|
|
// bridge-api.lux.network serves exactly two paths: `/` ("Hello, Winston
|
|
// Logger!") and `/health`, which embeds the MPC quorum state. There is no fee
|
|
// API behind the owner's "manage bridge fees" — the fee surface exists only in
|
|
// the MPC console at /bridge/fees, which is why that route is asserted as
|
|
// protected above. Recording the shape here so the gap is measured, not
|
|
// remembered.
|
|
test.describe('Bridge API', () => {
|
|
test('/health reports the MPC quorum', async ({ request }) => {
|
|
const res = await request.get('https://bridge-api.lux.network/health', { timeout: 15_000 })
|
|
console.log(` bridge-api /health: ${res.status()}`)
|
|
expect(res.status()).toBe(200)
|
|
const body = await res.text()
|
|
const json = JSON.parse(body) as Record<string, unknown>
|
|
console.log(` ${JSON.stringify(json).slice(0, 300)}`)
|
|
const mpc = JSON.stringify(json)
|
|
expect(mpc, 'bridge-api /health no longer reports mpc node state').toMatch(/totalNodes|activeNodes|threshold/i)
|
|
})
|
|
|
|
test('there is still no /v1 fee API to manage', async ({ request }) => {
|
|
// Asserted so the day one appears, this test goes red and the admin dash
|
|
// can stop screen-scraping the MPC console for fees.
|
|
const status = await httpStatus(request, 'https://bridge-api.lux.network/v1/health')
|
|
console.log(` bridge-api /v1/health: ${status} (404 = still no /v1 surface)`)
|
|
expect(status).toBe(404)
|
|
})
|
|
})
|