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]>
326 lines
14 KiB
TypeScript
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')
|
|
})
|