The stLUX vault page was removed from the console — no vault is configured for any brand, so the surface only ever said so, and staking returns are served by /dashboard/calculator and /dashboard/validators from the P-Chain. lux.cloud is a catch-all origin, so the old path still paints the console's "No such section"; a bodyLen assertion against that shell passes and proves nothing. It moves to NOT_SERVED, where the registry keeps retired paths visible. Co-authored-by: Hanzo Dev <[email protected]>
319 lines
15 KiB
TypeScript
319 lines
15 KiB
TypeScript
import { test, expect, Page, Response } from '@playwright/test'
|
||
import { mkdirSync, writeFileSync } from 'fs'
|
||
import { join } from 'path'
|
||
import { env, SHOT_DIR, viewportName } from './env'
|
||
import { signIn, snap } from './helpers'
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// THE INSTITUTIONAL BASELINE — what lux.cloud is like for an institution today
|
||
// ---------------------------------------------------------------------------
|
||
// This spec does not decide whether the product is good. It MEASURES the six
|
||
// things an institutional custody/infrastructure console is judged on, screen
|
||
// by screen, and writes the evidence next to the screenshots so the score in
|
||
// the review can be checked against something other than an opinion:
|
||
//
|
||
// 1. Does the screen render at all (nav status, not just "the SPA is 200").
|
||
// 2. Does it say what it is (an h1 / page heading).
|
||
// 3. How much of it is a ZERO or a placeholder — and is that zero EXPLAINED.
|
||
// Mainnet C-Chain is genuinely idle right now, so a flat number there is
|
||
// CORRECT; "0/5 CONNECTED" and 0.0000 uptime are defects. An institution
|
||
// cannot tell those apart unless the screen says which it is, so we record
|
||
// every zero WITH THE LABEL NEXT TO IT and let the review adjudicate.
|
||
// 4. Did any data call actually fail (4xx/5xx XHR, console error). A zero
|
||
// with a failed fetch behind it is a BUG; a zero with a clean 200 behind
|
||
// it is the chain being quiet.
|
||
// 5. Is it still loading after it has had its time (a permanent skeleton is
|
||
// the worst of the three states — it never resolves to either answer).
|
||
// 6. Is there a trust signal at all: a block height, a timestamp, a
|
||
// "last updated", a network name, an explorer link to verify against.
|
||
//
|
||
// It runs at all three viewports (playwright projects) because "institutional"
|
||
// includes the phone an operator approves from.
|
||
//
|
||
// LUX_SHOT_SET=institutional-baseline LUX_E2E_PASSWORD=… \
|
||
// npx playwright test e2e/51-institutional-baseline.spec.ts
|
||
//
|
||
// Read-only. It signs in and it looks. It never submits a form, never signs,
|
||
// never broadcasts — every value-moving control on these screens is
|
||
// owner-gated and this suite must never be the thing that trips one.
|
||
|
||
const CLOUD = env.urls.cloud
|
||
|
||
interface Screen {
|
||
id: string
|
||
path: string
|
||
group: string
|
||
// Built and routable, but absent from the signed-in navigation — an
|
||
// institutional user cannot reach it without being told the URL.
|
||
unlinked?: boolean
|
||
// No session required; these are the two screens an investor sees first.
|
||
anon?: boolean
|
||
}
|
||
|
||
// Groups mirror the signed-in sidebar exactly (measured 2026-07-26), so a gap
|
||
// in this list is a gap in the product, not in the test.
|
||
const SCREENS: Screen[] = [
|
||
{ id: '00-landing', path: '/', group: 'Pre-auth', anon: true },
|
||
{ id: '01-login', path: '/login', group: 'Pre-auth', anon: true },
|
||
|
||
{ id: '10-overview', path: '/dashboard', group: 'Overview' },
|
||
{ id: '11-analytics', path: '/dashboard/analytics', group: 'Overview' },
|
||
|
||
{ id: '20-validators', path: '/dashboard/validators', group: 'Infrastructure' },
|
||
{ id: '21-rpc', path: '/dashboard/rpc', group: 'Infrastructure' },
|
||
{ id: '22-chains', path: '/dashboard/chains', group: 'Infrastructure' },
|
||
{ id: '23-storage', path: '/dashboard/storage', group: 'Infrastructure' },
|
||
{ id: '24-deploy', path: '/dashboard/deploy', group: 'Infrastructure' },
|
||
|
||
{ id: '31-calculator', path: '/dashboard/calculator', group: 'Assets & DeFi' },
|
||
{ id: '32-treasury', path: '/dashboard/treasury', group: 'Assets & DeFi' },
|
||
{ id: '33-safes', path: '/dashboard/safes', group: 'Assets & DeFi' },
|
||
{ id: '34-mpc', path: '/dashboard/mpc', group: 'Assets & DeFi' },
|
||
{ id: '35-dex', path: '/dashboard/dex', group: 'Assets & DeFi' },
|
||
{ id: '36-bridge', path: '/dashboard/bridge', group: 'Assets & DeFi' },
|
||
|
||
{ id: '40-explorer', path: '/dashboard/explorer', group: 'Observability' },
|
||
{ id: '41-monitoring', path: '/dashboard/monitoring', group: 'Observability' },
|
||
|
||
{ id: '50-iam', path: '/dashboard/iam', group: 'Identity & Billing' },
|
||
{ id: '51-billing', path: '/dashboard/billing', group: 'Identity & Billing' },
|
||
|
||
// Routes that EXIST in the app but are not linked from the signed-in shell.
|
||
// Several of them are exactly what the institutional bar asks for (wallets,
|
||
// API keys, team/roles), so their absence from the nav is itself a finding.
|
||
{ id: '60-wallets', path: '/dashboard/wallets', group: 'Unlinked', unlinked: true },
|
||
{ id: '61-assets', path: '/dashboard/assets', group: 'Unlinked', unlinked: true },
|
||
{ id: '62-api-keys', path: '/dashboard/api-keys', group: 'Unlinked', unlinked: true },
|
||
{ id: '63-team', path: '/dashboard/organization/team', group: 'Unlinked', unlinked: true },
|
||
{ id: '64-settings', path: '/dashboard/settings', group: 'Unlinked', unlinked: true },
|
||
{ id: '65-webhooks', path: '/dashboard/webhooks', group: 'Unlinked', unlinked: true },
|
||
{ id: '66-usage', path: '/dashboard/usage', group: 'Unlinked', unlinked: true },
|
||
{ id: '67-admin', path: '/admin', group: 'Unlinked', unlinked: true },
|
||
]
|
||
|
||
// ── What counts as a zero ──────────────────────────────────────────────────
|
||
// A number that is entirely zero, a currency zero, or a typographic stand-in
|
||
// for "we have no value". `0.0000` and `0/5` are the two that burned us, so
|
||
// both are matched explicitly rather than by a loose /0/.
|
||
const ZERO = /(?:\$\s?0(?:\.0+)?|\b0(?:\.0+)?\s?%|\b0(?:\.0+)?\b|\b0\s*\/\s*\d+\b|—|–|\bN\/?A\b|--)/g
|
||
|
||
// The label a zero is sitting under, so the review can say WHICH zero.
|
||
// 48 chars of preceding text is enough to carry "Total Value Locked" or
|
||
// "UPTIME" without dragging in the whole card.
|
||
function zerosWithLabels(text: string): { label: string; value: string }[] {
|
||
const out: { label: string; value: string }[] = []
|
||
for (const m of text.matchAll(ZERO)) {
|
||
const before = text.slice(Math.max(0, m.index! - 48), m.index!).trim()
|
||
out.push({ label: before.slice(-48), value: m[0] })
|
||
}
|
||
return out
|
||
}
|
||
|
||
// A screen earns trust by letting you check it against the chain. Any ONE of
|
||
// these is enough: a block height, a freshness stamp, a chain id, or a link out
|
||
// to the explorer where the same number can be read independently.
|
||
const TRUST = [
|
||
/block\s*#?\s*\d/i,
|
||
/chain\s*id\s*\d/i,
|
||
/\b\d+\s*(?:seconds?|secs?|minutes?|mins?|hours?)\s+ago\b/i,
|
||
/last\s+(?:updated|synced|refreshed)/i,
|
||
/explorer/i,
|
||
]
|
||
|
||
// Copy that EXPLAINS an absence instead of just printing 0. This is the single
|
||
// highest-leverage thing missing from the product, so it gets measured directly.
|
||
const EXPLAINED_EMPTY = [
|
||
/no\s+\w+\s+yet/i,
|
||
/nothing\s+to\s+show/i,
|
||
/network\s+is\s+idle/i,
|
||
/\bidle\b/i,
|
||
/get\s+started/i,
|
||
/create\s+your\s+first/i,
|
||
/not\s+(?:yet\s+)?(?:available|deployed|configured|connected)/i,
|
||
/unavailable/i,
|
||
/couldn'?t\s+load|failed\s+to\s+load|unable\s+to\s+load/i,
|
||
]
|
||
|
||
// Still-rendering after we have already waited. A skeleton that never resolves
|
||
// reads as a hang, and an institution reads a hang as an outage.
|
||
//
|
||
// The verbs are the ones this product actually prints. The first version of
|
||
// this list only had loading/fetching/please-wait, and it passed the DEX page
|
||
// at 390px while the screenshot plainly said "Reading pools, reserves, and
|
||
// markets from chain…" — a detector that misses the real copy is just a
|
||
// slower way of not looking.
|
||
const STILL_LOADING =
|
||
/\b(loading|fetching|reading|checking|resolving|syncing)\b[^.]{0,60}…|\bplease\s+wait\b|^…$/i
|
||
|
||
interface Row {
|
||
id: string
|
||
group: string
|
||
path: string
|
||
unlinked: boolean
|
||
viewport: string
|
||
status: number
|
||
heading: string
|
||
chars: number
|
||
zeros: { label: string; value: string }[]
|
||
zeroCount: number
|
||
trustSignals: string[]
|
||
explainedEmpty: string[]
|
||
stillLoading: boolean
|
||
failedCalls: { url: string; status: number }[]
|
||
consoleErrors: string[]
|
||
shot: string
|
||
}
|
||
|
||
// Only the app's OWN data calls matter — a blocked third-party beacon is not a
|
||
// defect in the console. Host-matched, not substring-matched.
|
||
function isOwnCall(url: string): boolean {
|
||
try {
|
||
const h = new URL(url).host
|
||
return /(^|\.)lux\.(cloud|network|id|exchange|build)$/.test(h)
|
||
|| /(^|\.)(hanzo|zoo)\.(ai|network|ngo|id)$/.test(h)
|
||
} catch { return false }
|
||
}
|
||
|
||
async function measure(page: Page, s: Screen): Promise<Row> {
|
||
const failedCalls: { url: string; status: number }[] = []
|
||
const consoleErrors: string[] = []
|
||
|
||
const onResponse = (r: Response) => {
|
||
if (r.status() >= 400 && isOwnCall(r.url())) {
|
||
failedCalls.push({ url: r.url().slice(0, 160), status: r.status() })
|
||
}
|
||
}
|
||
const onConsole = (m: { type(): string; text(): string }) => {
|
||
if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 200))
|
||
}
|
||
page.on('response', onResponse)
|
||
page.on('console', onConsole)
|
||
|
||
let status = 0
|
||
try {
|
||
const res = await page.goto(`${CLOUD}${s.path}`, {
|
||
waitUntil: 'domcontentloaded', timeout: 60_000,
|
||
})
|
||
status = res?.status() ?? 0
|
||
} catch { status = 0 }
|
||
|
||
// Hydration, then the data calls, then a beat for them to paint. Generous on
|
||
// purpose: a screen judged "stuck loading" must have genuinely had its time.
|
||
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {})
|
||
await page.waitForTimeout(4000)
|
||
|
||
const text = ((await page.textContent('body').catch(() => '')) || '').replace(/\s+/g, ' ')
|
||
const heading = (await page.locator('h1, h2').first().textContent().catch(() => ''))?.trim() || ''
|
||
|
||
const shot = `institutional-${s.id}`
|
||
await snap(page, shot)
|
||
|
||
page.off('response', onResponse)
|
||
page.off('console', onConsole)
|
||
|
||
const zeros = zerosWithLabels(text)
|
||
return {
|
||
id: s.id,
|
||
group: s.group,
|
||
path: s.path,
|
||
unlinked: !!s.unlinked,
|
||
viewport: viewportName(page.viewportSize()),
|
||
status,
|
||
heading,
|
||
chars: text.length,
|
||
zeros: zeros.slice(0, 40),
|
||
zeroCount: zeros.length,
|
||
trustSignals: TRUST.filter((re) => re.test(text)).map(String),
|
||
explainedEmpty: EXPLAINED_EMPTY.filter((re) => re.test(text)).map(String),
|
||
stillLoading: STILL_LOADING.test(text),
|
||
failedCalls,
|
||
consoleErrors,
|
||
shot: `${shot}-${viewportName(page.viewportSize())}.png`,
|
||
}
|
||
}
|
||
|
||
test.describe('Institutional baseline — lux.cloud, signed in', () => {
|
||
// One sign-in, then the whole surface in that session. Serial by file, so the
|
||
// session established by the first test is the session the rest use.
|
||
test.describe.configure({ mode: 'serial' })
|
||
|
||
const rows: Row[] = []
|
||
|
||
test('walk every screen an institutional user can reach', async ({ page }) => {
|
||
// 27 screens × (nav + networkidle + 4s + shot). Generous, measured ~9s each.
|
||
test.setTimeout(20 * 60_000)
|
||
|
||
// The two pre-auth screens first — an investor's first impression is formed
|
||
// before there is a session, and it is part of the baseline.
|
||
for (const s of SCREENS.filter((x) => x.anon)) {
|
||
rows.push(await measure(page, s))
|
||
}
|
||
|
||
const jwt = await signIn(page)
|
||
// Assert the POSITIVE signal. "no error string" gave two false passes.
|
||
const claims = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'))
|
||
expect(claims.iss, 'token not issued by the IdP we signed in at').toBe(env.urls.id)
|
||
await expect(page.locator(':text-matches("sign\\\\s*out", "i")').first())
|
||
.toHaveCount(1, { timeout: 15_000 })
|
||
await expect(page.getByText(/Nodes & Validators/i).first()).toBeAttached({ timeout: 15_000 })
|
||
await expect(page.getByText(/Chains/i).first()).toBeAttached({ timeout: 15_000 })
|
||
|
||
for (const s of SCREENS.filter((x) => !x.anon)) {
|
||
rows.push(await measure(page, s))
|
||
// Every authenticated screen must still be an authenticated screen — a
|
||
// silent session drop mid-walk would make the rest of the run measure the
|
||
// logged-out shell and call it the product.
|
||
expect(await page.locator(':text-matches("sign\\\\s*out", "i")').count(),
|
||
`session lost at ${s.path}`).toBeGreaterThan(0)
|
||
}
|
||
})
|
||
|
||
// ── The two defects that are never a judgement call ──────────────────────
|
||
// Everything else on these screens is a design opinion and belongs in the
|
||
// review, not in a red build. These two are not opinions:
|
||
//
|
||
// • A screen still saying "Reading … from chain…" after domcontentloaded +
|
||
// networkidle + 4s has not rendered. Measured: /dashboard/dex at 390px
|
||
// showed exactly that while 26 of its RPC calls came back 429.
|
||
// • A route that silently renders a DIFFERENT page. The deployed console's
|
||
// router ends in a catch-all redirect to /dashboard, so /dashboard/
|
||
// api-keys shows the Overview under the Overview nav highlight. An
|
||
// institution that bookmarks the key-management URL lands on a page that
|
||
// looks legitimate and is not the one it asked for. Silence is worse than
|
||
// a 404 here: a 404 is information.
|
||
test('no screen hangs, and no route silently renders another page', async () => {
|
||
const overview = rows.find((r) => r.id === '10-overview')!
|
||
|
||
const hung = rows.filter((r) => r.stillLoading).map((r) => `${r.path} (${r.viewport})`)
|
||
const misrouted = rows
|
||
.filter((r) => r.id !== '10-overview' && r.heading && r.heading === overview.heading)
|
||
.map((r) => `${r.path} → "${r.heading}"`)
|
||
|
||
expect(hung, 'screens that never finished rendering').toEqual([])
|
||
expect(misrouted, 'routes that silently rendered a different page').toEqual([])
|
||
})
|
||
|
||
test.afterAll(async () => {
|
||
if (!rows.length) return
|
||
mkdirSync(SHOT_DIR, { recursive: true })
|
||
const vp = rows[0].viewport
|
||
const file = join(SHOT_DIR, `baseline-${vp}.json`)
|
||
writeFileSync(file, JSON.stringify({ capturedAgainst: CLOUD, env: env.name, viewport: vp, rows }, null, 2))
|
||
|
||
// The run states its own verdict, so a green tick is never the whole story.
|
||
console.log(`\n ── institutional baseline (${vp}) ──`)
|
||
for (const r of rows) {
|
||
const flags = [
|
||
r.status >= 400 || r.status === 0 ? `HTTP:${r.status}` : '',
|
||
r.stillLoading ? 'STUCK-LOADING' : '',
|
||
r.failedCalls.length ? `FAILED:${r.failedCalls.length}` : '',
|
||
r.zeroCount && !r.explainedEmpty.length ? `BARE-ZEROS:${r.zeroCount}` : '',
|
||
!r.trustSignals.length ? 'NO-TRUST-SIGNAL' : '',
|
||
r.unlinked ? 'UNLINKED' : '',
|
||
].filter(Boolean).join(' ')
|
||
console.log(` ${r.id.padEnd(16)} ${String(r.chars).padStart(6)}ch ${flags || 'ok'}`)
|
||
}
|
||
console.log(` evidence: ${file}\n`)
|
||
})
|
||
})
|