Files
universe/e2e/52-institutional-after.spec.ts
zeekayandHanzo AI b0c6e55220 e2e: the staking route is gone, so stop declaring it
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]>
2026-08-28 12:38:25 -07:00

326 lines
14 KiB
TypeScript

import { test, expect, Page } from '@playwright/test'
import { mkdirSync, writeFileSync } from 'fs'
import { join } from 'path'
// ---------------------------------------------------------------------------
// THE INSTITUTIONAL AFTER — the same screens as 51-institutional-baseline,
// captured against the REBUILT console.
// ---------------------------------------------------------------------------
//
// Read this before trusting a frame from it:
//
// • The CHAIN DATA is real and live. Every balance, block height, validator,
// Safe owner and threshold on these screens is read from mainnet at capture
// time, exactly as in production.
//
// • The SESSION is synthetic. lux.id cannot complete an OIDC redirect back to
// 127.0.0.1 (the client's redirect_uri is https://lux.cloud/auth/callback),
// so a signed-in console cannot be reached on a local build by signing in.
// A synthetic token is seeded into the storage keys the IAM SDK reads, and
// the userinfo call is intercepted. That makes the signed-in screens
// reachable; it does NOT make the identity real, and the Access screen will
// therefore show the synthetic claims. Nothing here proves anything about
// authentication — 12-iam-login.spec.ts is what proves that, against the
// deployed surface.
//
// • It is read-only. It signs nothing and submits nothing.
//
// Run against a local production build:
//
// cd ~/work/lux/cloud/apps/web && npx vite build && npx vite preview --port 4783
// LUX_AFTER_BASE=http://127.0.0.1:4783 \
// npx playwright test e2e/52-institutional-after.spec.ts
//
// Point LUX_AFTER_BASE at https://lux.cloud once this is deployed and the same
// spec captures the deployed surface instead — with a real session, if one is
// supplied the way 51 does it.
const BASE = process.env.LUX_AFTER_BASE ?? 'http://127.0.0.1:4783'
const OUT = process.env.LUX_AFTER_DIR ?? join(process.env.HOME!, 'Desktop/lux-e2e/institutional-after')
// The same screen list as the baseline, so every "after" frame has a "before"
// to sit next to. Two rows are new surfaces this work added; they are marked so
// the absence of a baseline frame is not read as a missing capture.
interface Screen {
id: string
path: string
/** Built by this work — there is no baseline frame to compare against. */
isNew?: boolean
/** Renders without a session. */
anon?: boolean
}
const SCREENS: Screen[] = [
{ id: '00-landing', path: '/', anon: true },
{ id: '01-login', path: '/login', anon: true },
{ id: '10-overview', path: '/dashboard' },
{ id: '11-analytics', path: '/dashboard/analytics' },
{ id: '20-validators', path: '/dashboard/validators' },
{ id: '21-rpc', path: '/dashboard/rpc' },
{ id: '22-chains', path: '/chains' },
{ id: '23-storage', path: '/dashboard/storage' },
{ id: '24-deploy', path: '/dashboard/deploy' },
{ id: '31-calculator', path: '/dashboard/calculator' },
{ id: '32-treasury', path: '/dashboard/treasury' },
{ id: '33-safes', path: '/dashboard/safes' },
{ id: '34-mpc', path: '/dashboard/mpc' },
{ id: '35-dex', path: '/dashboard/dex' },
{ id: '36-bridge', path: '/dashboard/bridge' },
{ id: '40-explorer', path: '/dashboard/explorer' },
{ id: '41-monitoring', path: '/dashboard/monitoring' },
{ id: '50-iam', path: '/dashboard/iam' },
{ id: '51-billing', path: '/dashboard/billing' },
{ id: '52-access', path: '/dashboard/access', isNew: true },
{ id: '60-unknown-route', path: '/dashboard/wallets', isNew: true },
{ id: '67-admin', path: '/dashboard/admin' },
]
// The viewport comes from the playwright PROJECT (laptop / tablet / mobile —
// see playwright.config.ts, which derives them from e2e/env.ts). Declaring a
// second set here would run every screen once per project per local viewport —
// nine passes for three distinct frames, all writing the same filenames.
// A syntactically valid, obviously-fake JWT. It is never sent anywhere that
// verifies it: the SDK reads it out of storage and the userinfo call is
// intercepted below. Marked `synthetic-capture` in every claim so a frame that
// leaks into a deck is self-identifying.
function syntheticToken(): string {
const b64 = (o: unknown) =>
Buffer.from(JSON.stringify(o)).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
const now = Math.floor(Date.now() / 1000)
return [
b64({ alg: 'RS256', typ: 'JWT', kid: 'synthetic-capture' }),
b64({
sub: 'lux/synthetic-capture',
iss: 'https://lux.id',
aud: 'lux-cloud',
iat: now,
exp: now + 3600,
email: '[email protected]',
name: 'Screenshot Capture',
owner: 'lux',
groups: ['synthetic-capture'],
}),
'not-a-signature',
].join('.')
}
async function seedSession(page: Page) {
const token = syntheticToken()
// The @hanzo/iam browser SDK defaults to sessionStorage, not localStorage
// (`this.storage = config.storage ?? sessionStorage`), with STORAGE_PREFIX
// "hanzo_iam_". It does not verify the signature client-side — it checks the
// expiry and calls userinfo — so seeding these three keys and intercepting
// userinfo is enough to reach the signed-in screens.
await page.addInitScript(
([t, exp]) => {
sessionStorage.setItem('hanzo_iam_access_token', t as string)
sessionStorage.setItem('hanzo_iam_id_token', t as string)
sessionStorage.setItem('hanzo_iam_expires_at', exp as string)
},
[token, String(Date.now() + 3600_000)],
)
// Anything the SDK asks the IdP is answered locally — no request leaves for
// lux.id during a capture.
await page.route(/https:\/\/lux\.id\/.*/, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
sub: 'lux/synthetic-capture',
name: 'Screenshot Capture',
owner: 'lux',
email: '[email protected]',
}),
}),
)
}
// What the frame is worth as evidence. The baseline counted unexplained zeros;
// this counts the same thing plus whether the new vocabulary is actually on the
// page, so "we added trust signals" is a measurement rather than a claim.
interface Measurement {
id: string
path: string
viewport: string
chars: number
h1: string | null
/** Figures rendered through the shared <Stat> primitive. */
stats: number
/** Stats whose value slot is an absence word rather than a number. */
labelledAbsences: number
/** Absence words present, e.g. unknown / unavailable / none. */
absenceWords: string[]
/** Quality rails on stats, by quality. */
qualities: Record<string, number>
/** Provenance lines (`.stat-src`) — a figure citing where it came from. */
provenance: number
/** Controls (`.stat-control`) — a second observation backing an absence. */
controls: number
/** Rows in "Needs attention". */
alerts: number
/** A bare "0" or "0.0%" NOT inside a labelled stat — the defect being hunted. */
bareZeros: string[]
/** Blank screen: almost no text and no heading. */
blank: boolean
/** Horizontal overflow — the page body scrolling sideways. */
hOverflow: boolean
/** Any element clipped outside the viewport width. */
clipped: string[]
consoleErrors: number
failedRequests: string[]
}
async function measure(page: Page, s: Screen, viewport: string): Promise<Measurement> {
return page.evaluate(
({ id, path, viewport }) => {
const text = (document.body.innerText || '').trim()
const h1 = document.querySelector('h1')?.textContent?.trim() ?? null
const stats = [...document.querySelectorAll('.stat')]
const absent = [...document.querySelectorAll('.stat-absent')]
const qualities: Record<string, number> = {}
for (const st of stats) {
const q = [...st.classList].find((c) => c.startsWith('q-'))
if (q) qualities[q.slice(2)] = (qualities[q.slice(2)] ?? 0) + 1
}
// A bare zero is one that is NOT inside a stat value and NOT adjacent to a
// word that explains it. This is the exact defect the work targets, so it
// is counted the same way before and after.
const bareZeros: string[] = []
const EXPLAINED = /(idle|empty|none|unknown|unavailable|not reported|no |zero)/i
for (const el of document.querySelectorAll('td, .stat-value, .trust-v, .netstate-facts b')) {
const t = (el.textContent || '').trim()
if (!/^(0|0\.0+%?|0\.0000|0 of \d+|0\/\d+)$/.test(t)) continue
const ctx = (el.closest('tr, .stat, .trust-cell')?.textContent || '').trim()
if (!EXPLAINED.test(ctx)) bareZeros.push(`${t} :: ${ctx.slice(0, 80)}`)
}
// CLIPPED means UNREACHABLE, which is not the same as "outside the
// viewport". A cell inside a horizontal scroller is off-screen but the user
// can reach it by swiping; a cell that overflows a non-scrolling ancestor
// cannot be reached at all. That was the actual Safes defect on a phone —
// THRESHOLD and OWNER off the right edge with no scroll affordance — so the
// check walks up for a scrollable ancestor before calling anything clipped.
const clipped: string[] = []
const vw = document.documentElement.clientWidth
const scrollableAncestor = (el: Element): boolean => {
for (let p = el.parentElement; p; p = p.parentElement) {
const ov = getComputedStyle(p).overflowX
if ((ov === 'auto' || ov === 'scroll') && p.scrollWidth > p.clientWidth + 1) return true
}
return false
}
for (const el of document.querySelectorAll('th, td, .stat, .pill, .btn')) {
const r = el.getBoundingClientRect()
if (r.width === 0) continue
if (r.right <= vw + 1 && r.left >= -1) continue
if (scrollableAncestor(el)) continue
clipped.push(`${el.tagName.toLowerCase()}.${el.className}`.slice(0, 60))
}
return {
id,
path,
viewport,
chars: text.length,
h1,
stats: stats.length,
labelledAbsences: absent.length,
absenceWords: [...new Set(absent.map((a) => (a.textContent || '').trim()).filter(Boolean))],
qualities,
provenance: document.querySelectorAll('.stat-src').length,
controls: document.querySelectorAll('.stat-control').length,
alerts: document.querySelectorAll('.alert').length,
bareZeros,
blank: text.length < 120 && !h1,
hOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
clipped: [...new Set(clipped)].slice(0, 8),
consoleErrors: 0,
failedRequests: [],
} as Measurement
},
{ id: s.id, path: s.path, viewport },
)
}
test('capture every screen', async ({ page }, testInfo) => {
const vpName = testInfo.project.name
test.setTimeout(8 * 60_000)
mkdirSync(OUT, { recursive: true })
const consoleErrors: string[] = []
const failed: string[] = []
page.on('console', (m) => m.type() === 'error' && consoleErrors.push(m.text().slice(0, 160)))
page.on('response', (r) => {
if (r.status() >= 400 && !r.url().startsWith(BASE)) failed.push(`${r.status()} ${r.url().slice(0, 90)}`)
})
await seedSession(page)
const results: Measurement[] = []
for (const s of SCREENS) {
consoleErrors.length = 0
failed.length = 0
await page.goto(BASE + s.path, { waitUntil: 'domcontentloaded' })
// Chain reads are throttled and retried; give them room to resolve so a
// capture never records a spinner that would have filled in.
await page.waitForTimeout(6000)
const m = await measure(page, s, vpName)
m.consoleErrors = consoleErrors.length
m.failedRequests = [...new Set(failed)].slice(0, 6)
results.push(m)
await page.screenshot({ path: join(OUT, `institutional-${s.id}-${vpName}.png`), fullPage: true })
}
writeFileSync(join(OUT, `after-${vpName}.json`), JSON.stringify(results, null, 2))
// ---- the assertions that make this a test rather than a slideshow ----
// Positive signals only, per the standing rule: assert what must BE there,
// never the absence of an error string.
// 1. Nothing renders blank. The five embed screens were white voids.
expect(results.filter((r) => r.blank).map((r) => r.id), 'no screen may render blank').toEqual([])
// 2. The body never scrolls sideways at any viewport.
expect(results.filter((r) => r.hOverflow).map((r) => r.id), 'the page body must never scroll horizontally').toEqual([])
// 3. Nothing is clipped outside the viewport — the Safes table used to put
// THRESHOLD and OWNER off-screen with no scroll affordance.
expect(
results.filter((r) => r.clipped.length > 0).map((r) => `${r.id}: ${r.clipped.join(', ')}`),
'no cell or control may sit outside the viewport',
).toEqual([])
// 4. No bare unexplained zero anywhere.
expect(
results.filter((r) => r.bareZeros.length > 0).map((r) => `${r.id}: ${r.bareZeros.join(' | ')}`),
'every zero must be labelled idle / empty / unknown / unavailable',
).toEqual([])
// 5. Every figure that IS shown cites where it came from.
for (const r of results.filter((x) => x.stats > 0)) {
expect(r.provenance, `${r.id}: every stat must carry provenance`).toBeGreaterThanOrEqual(r.stats)
}
// 6. The screens that state money or custody must render through the shared
// primitive — that is what makes a bare number impossible on them.
for (const id of ['10-overview', '32-treasury', '33-safes', '34-mpc', '36-bridge']) {
const r = results.find((x) => x.id === id)!
expect(r.stats, `${id}: must render figures through the shared primitive`).toBeGreaterThan(0)
}
// 7. Positive identity signal: the console shell is present on a signed-in
// screen. Asserted by what IS there, never by the absence of an error.
const treasury = results.find((r) => r.id === '32-treasury')!
expect(treasury.h1, 'the treasury screen must name itself').toBe('Treasury')
})