Durable regression suite proving every public surface across Lux/Zoo/Hanzo/Pars
(explorers, wallets, id, votes, work boards, bridges, safe, clouds, exchanges)
is live, functional, and correctly branded at 390/768/1280/1920 — real
Chrome/Blink (channel:'chrome'), read-only (no txns, no wallet-connect, no auth;
the login form is asserted, never submitted).
- e2e/branded/surfaces.ts — ONE source-of-truth table (surface × posture × flags);
breakpoint-scoped flags carry a reason + flip condition
- e2e/branded/branded.spec.ts — table-driven 8-check battery (the owner's 6-point
rubric: 200 · branded head + zero cross-brand leak ·
no overflow · no app-console-errors · key control
visible · brand font); gate vs flag vs stale-flag
- e2e/branded/matrix-reporter.ts — renders the surface × breakpoint PASS/FLAG/FAIL
grid + defect ledger; writes branded-report/matrix.{md,txt,json}
- playwright.branded.config.ts — breakpoints as projects, chrome channel, matrix reporter
- e2e.yml `branded` job + README + LLM.md section
Baseline 104 cells: 67 pass · 37 flagged (each with a flip condition) · 0
regressions. Only a NEW un-flagged failure turns the run red. Screenshots →
~/Desktop/lux-shots-e2e/. Bumps @luxfi/universe 1.0.1 → 1.0.2.
Co-authored-by: Hanzo Dev <[email protected]>
239 lines
14 KiB
TypeScript
239 lines
14 KiB
TypeScript
import { test, expect, Page } from '@playwright/test'
|
||
import { mkdirSync } from 'fs'
|
||
import { homedir } from 'os'
|
||
import { join } from 'path'
|
||
import { SURFACES, ORG_BRANDS, BRAND_TOKENS, type Surface, type Check, type Brand } from './surfaces'
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Branded web3-surface regression — surfaces × 4 breakpoints, real Blink (Chrome).
|
||
// ---------------------------------------------------------------------------
|
||
// Read-only against LIVE production surfaces: NO transactions, NO wallet connect,
|
||
// NO auth. We assert the login/connect FORM renders — never authenticate.
|
||
// Breakpoints are Playwright PROJECTS (390/768/1280/1920) → the matrix is
|
||
// (surface row × project column). Each test runs the full 6-point battery, then
|
||
// consults surfaces.ts to split failures into EXPECTED (documented flags → the
|
||
// cell is FLAG, run stays green) vs UNEXPECTED (a real regression → the test
|
||
// FAILS red). Results are attached as JSON for the matrix reporter.
|
||
|
||
const SHOTS = join(homedir(), 'Desktop', 'lux-shots-e2e')
|
||
mkdirSync(SHOTS, { recursive: true })
|
||
|
||
// Third-party telemetry / benign browser-chrome noise that is NOT an app-origin
|
||
// error. Allow-listed with rationale — a matching console.error never gates.
|
||
const CONSOLE_ALLOWLIST: { re: RegExp; why: string }[] = [
|
||
{ re: /Failed to load resource: the server responded with a status of (403|404|429)/i, why: 'benign resource 4xx (missing icon/optional endpoint) — does not break render' },
|
||
{ re: /Content Security Policy directive .* is ignored when delivered via a <meta>/i, why: 'CSP-in-<meta> browser advisory, not an app error' },
|
||
{ re: /cloudflareinsights|beacon\.min\.js/i, why: 'Cloudflare web-analytics beacon (3rd-party telemetry)' },
|
||
{ re: /walletconnect|pulse\.walletconnect|explorer-api\.walletconnect|web3modal/i, why: 'WalletConnect telemetry/registry (3rd-party)' },
|
||
{ re: /Trustpilot/i, why: '3rd-party Trustpilot widget' },
|
||
{ re: /google-analytics|googletagmanager|gtag|hotjar|segment\.io|sentry/i, why: '3rd-party analytics/monitoring' },
|
||
{ re: /downloadable font|Failed to decode downloaded font|OTS parsing error/i, why: 'font CDN advisory (captured separately by the font check)' },
|
||
]
|
||
|
||
interface Probe {
|
||
status: number
|
||
reachable: boolean
|
||
gotoErr: string
|
||
title: string
|
||
headText: string // title + all <meta> content joined — the branded <head>
|
||
headAssets: string // favicon / apple-touch / logo asset URLs joined
|
||
faviconHref: string
|
||
bodyFont: string
|
||
scrollWidth: number
|
||
innerWidth: number
|
||
rootKids: number
|
||
bodyLen: number
|
||
bodySample: string
|
||
keyMatch: string // accessible-name of the first VISIBLE control matching surface.key
|
||
bodyForeign: string[] // foreign org brands appearing in BODY (observational note only)
|
||
accents: string[] // prominent rendered accent colors (observational)
|
||
}
|
||
|
||
// One in-page read of everything the checks need. Deterministic, single round-trip.
|
||
async function readSurface(page: Page, s: Surface): Promise<Probe> {
|
||
return page.evaluate(({ keySource, brand, brands }) => {
|
||
const keyRe = new RegExp(keySource, 'i')
|
||
const vis = (el: Element) => {
|
||
const r = el.getBoundingClientRect()
|
||
const st = getComputedStyle(el)
|
||
return r.width > 0 && r.height > 0 && st.visibility !== 'hidden' && st.display !== 'none' && st.opacity !== '0'
|
||
}
|
||
const metas = Array.from(document.querySelectorAll('meta[content]'))
|
||
.map((m) => m.getAttribute('content') || '').join(' | ')
|
||
const iconEls = Array.from(document.querySelectorAll('link[rel~="icon"], link[rel="shortcut icon"], link[rel*="apple-touch"]'))
|
||
const logoEls = Array.from(document.querySelectorAll('header img, nav img, a[href="/"] img, [class*="logo" i] img'))
|
||
const headAssets = [...iconEls.map((l) => l.getAttribute('href') || ''), ...logoEls.map((i) => i.getAttribute('src') || '')].join(' | ')
|
||
const favicon = (document.querySelector('link[rel~="icon"]') || document.querySelector('link[rel="shortcut icon"]'))?.getAttribute('href') || ''
|
||
|
||
// First VISIBLE interactive control whose accessible-name matches the key regex.
|
||
let keyMatch = ''
|
||
for (const el of Array.from(document.querySelectorAll('button, a[href], input, [role="button"], [role="link"]'))) {
|
||
if (!vis(el)) continue
|
||
const name = ((el as HTMLElement).innerText || el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.getAttribute('value') || '').trim()
|
||
if (name && keyRe.test(name)) { keyMatch = name.slice(0, 40); break }
|
||
}
|
||
|
||
const bt = (document.body?.innerText || '').replace(/\s+/g, ' ').trim()
|
||
const bodyForeign = brands.filter((b) => b !== brand && new RegExp('\\b' + b + '\\b').test(bt))
|
||
|
||
// Prominent accent colors from styled controls (observational).
|
||
const neutral = (c: string) => !c || /rgba?\(0,\s*0,\s*0(,|\))|rgb\(255,\s*255,\s*255\)|rgba?\(0,\s*0,\s*0,\s*0\)|rgb\(0,\s*0,\s*0\)/.test(c.replace(/\s/g, ''))
|
||
const accents = new Set<string>()
|
||
for (const el of Array.from(document.querySelectorAll('button, a, [class*="btn" i], [class*="primary" i]')).filter(vis).slice(0, 40)) {
|
||
const st = getComputedStyle(el)
|
||
if (!neutral(st.backgroundColor)) accents.add(st.backgroundColor)
|
||
if (!neutral(st.color)) accents.add(st.color)
|
||
}
|
||
|
||
return {
|
||
title: document.title,
|
||
headText: (document.title + ' | ' + metas),
|
||
headAssets,
|
||
faviconHref: favicon,
|
||
bodyFont: getComputedStyle(document.body).fontFamily,
|
||
scrollWidth: document.documentElement.scrollWidth,
|
||
innerWidth: window.innerWidth,
|
||
rootKids: (document.getElementById('root') || document.getElementById('app') || document.getElementById('__next') || document.body)?.querySelectorAll('*').length ?? -1,
|
||
bodyLen: bt.length,
|
||
bodySample: bt.slice(0, 160),
|
||
keyMatch,
|
||
bodyForeign,
|
||
accents: [...accents].slice(0, 8),
|
||
}
|
||
}, { keySource: s.key.source, brand: s.brand, brands: ORG_BRANDS as string[] }) as Promise<Omit<Probe, 'status' | 'reachable' | 'gotoErr'>> as any
|
||
}
|
||
|
||
// Classify a rendered accent set against the brand token hue (observational note).
|
||
function accentNote(brand: Brand, accents: string[]): string {
|
||
const want = BRAND_TOKENS[brand].accent
|
||
const hue = (c: string): string => {
|
||
const m = c.match(/(\d+),\s*(\d+),\s*(\d+)/)
|
||
if (!m) return ''
|
||
const [r, g, b] = [+m[1], +m[2], +m[3]]
|
||
if (b > 150 && b > g + 20 && r > g - 10 && r < b) return 'violet'
|
||
if (g > 150 && g > r + 30 && g > b) return 'green'
|
||
if (r > 150 && r > g + 30 && g > b) return 'orange'
|
||
if (r > 180 && g > 150 && b < 120) return 'gold'
|
||
return ''
|
||
}
|
||
const hues = new Set(accents.map(hue).filter(Boolean))
|
||
if (hues.has(want)) return `accent ${want} ✓`
|
||
if (hues.size) return `accent ${[...hues].join('/')} (brand token = ${want})`
|
||
return `accent undetected (brand token = ${want}; dark-theme neutral controls)`
|
||
}
|
||
|
||
interface CheckResult { name: Check; ok: boolean; detail: string }
|
||
|
||
for (const s of SURFACES) {
|
||
test.describe(`${s.category} · ${s.id} (${s.brand})`, () => {
|
||
test(s.id, async ({ page }, testInfo) => {
|
||
const bp = testInfo.project.name
|
||
const consoleErrs: string[] = []
|
||
const pageErrs: string[] = []
|
||
page.on('console', (m) => { if (m.type() === 'error') consoleErrs.push(m.text()) })
|
||
page.on('pageerror', (e) => pageErrs.push(String(e)))
|
||
|
||
// ── Load (read-only; goto may throw for a documented-down surface) ──────
|
||
let status = 0, reachable = false, gotoErr = ''
|
||
try {
|
||
const res = await page.goto(s.url, { waitUntil: 'domcontentloaded', timeout: 45_000 })
|
||
status = res?.status() ?? 0
|
||
reachable = true
|
||
await page.waitForTimeout(4500) // let the SPA hydrate + fire late console errors
|
||
} catch (e) {
|
||
gotoErr = String(e).split('\n')[0]
|
||
}
|
||
|
||
const empty = { title: '', headText: '', headAssets: '', faviconHref: '', bodyFont: '', scrollWidth: 0, innerWidth: 0, rootKids: -1, bodyLen: 0, bodySample: '', keyMatch: '', bodyForeign: [] as string[], accents: [] as string[] }
|
||
// Bound the DOM read so a heavy/broken page (e.g. exchange-fork thrash) can
|
||
// never hang the whole test — we classify from partial data + flags instead.
|
||
const read = reachable
|
||
? await Promise.race<Omit<Probe, 'status' | 'reachable' | 'gotoErr'>>([
|
||
readSurface(page, s),
|
||
new Promise((r) => setTimeout(() => r(empty), 12_000)),
|
||
]).catch(() => empty)
|
||
: empty
|
||
const p: Probe = { status, reachable, gotoErr, ...read }
|
||
|
||
// Screenshot every cell (best-effort, bounded).
|
||
if (reachable) await page.screenshot({ path: join(SHOTS, `${s.id}-${bp}.png`), fullPage: false, timeout: 12_000 }).catch(() => {})
|
||
|
||
// ── The six-point battery → eight concrete checks ───────────────────────
|
||
const foreign = ORG_BRANDS.filter((b) => b !== s.brand)
|
||
// Head/asset leak = the white-label invariant. Body brand references (bridge
|
||
// "Lux C-Chain" source chain, wallet "Avalanche" network) are legitimate and
|
||
// captured only as a note.
|
||
const headLeaks = foreign.filter((b) =>
|
||
new RegExp('\\b' + b + '\\b').test(p.headText) ||
|
||
new RegExp('(^|[/_-])' + b + '([/_. -]|$)', 'i').test(p.headAssets))
|
||
const brokenSerif = /(^|,)\s*Times(\b|,|$)|(^|,)\s*serif\s*$|Arial,\s*serif/i.test(p.bodyFont)
|
||
const badConsole = consoleErrs.filter((t) => !CONSOLE_ALLOWLIST.some((a) => a.re.test(t)))
|
||
const appErrs = [...pageErrs, ...badConsole]
|
||
|
||
const checks: CheckResult[] = [
|
||
{ name: 'http', ok: status === 200, detail: reachable ? `HTTP ${status}` : `unreachable: ${gotoErr}` },
|
||
{ name: 'title', ok: new RegExp('\\b' + s.brand + '\\b', 'i').test(p.title), detail: JSON.stringify(p.title) },
|
||
{ name: 'favicon', ok: !!p.faviconHref && p.faviconHref !== 'data:,', detail: p.faviconHref ? p.faviconHref.slice(0, 48) : '(none)' },
|
||
{ name: 'leak', ok: headLeaks.length === 0, detail: headLeaks.length ? `HEAD leaks ${headLeaks.join(',')}` : 'clean head' },
|
||
{ name: 'overflow', ok: reachable && p.scrollWidth - p.innerWidth <= 2, detail: `scrollW ${p.scrollWidth} vs innerW ${p.innerWidth}` },
|
||
{ name: 'console', ok: appErrs.length === 0, detail: appErrs.length ? `${appErrs.length} app err(s): ${appErrs.slice(0, 2).map((e) => e.slice(0, 90)).join(' ⁙ ')}` : '0 app errors' },
|
||
{ name: 'key', ok: !!p.keyMatch, detail: p.keyMatch ? `visible: "${p.keyMatch}"` : `no visible control matching ${s.key}` },
|
||
{ name: 'font', ok: reachable && !brokenSerif, detail: p.bodyFont.slice(0, 44) },
|
||
]
|
||
|
||
// ── Gate vs flag ────────────────────────────────────────────────────────
|
||
// posture 'down'/'foreign-domain' → nothing gates (whole surface is a FLAG).
|
||
const nonGating = s.posture !== 'live'
|
||
const covers = (fl: { bps?: string[] } | undefined) => !!fl && (!fl.bps || fl.bps.includes(bp))
|
||
const failed = checks.filter((c) => !c.ok)
|
||
const flagged: { check: Check; reason: string; flip: string; detail: string }[] = []
|
||
const unexpected: CheckResult[] = []
|
||
for (const f of failed) {
|
||
const known = s.flags[f.name]
|
||
if (nonGating) {
|
||
const g = s.flags[f.name] || s.flags.http
|
||
flagged.push({ check: f.name, reason: g?.reason || `${s.posture} surface`, flip: g?.flip || '', detail: f.detail })
|
||
} else if (covers(known)) {
|
||
flagged.push({ check: f.name, reason: known!.reason, flip: known!.flip, detail: f.detail })
|
||
} else {
|
||
unexpected.push(f) // no flag, or a flag that does not cover THIS breakpoint → gates
|
||
}
|
||
}
|
||
// A documented flag whose check is PASSING at a breakpoint it claims → stale.
|
||
const staleFlags = (Object.keys(s.flags) as Check[]).filter(
|
||
(k) => s.posture === 'live' && covers(s.flags[k]) && checks.find((c) => c.name === k)?.ok,
|
||
)
|
||
|
||
const accNote = accentNote(s.brand, p.accents)
|
||
const cellState = unexpected.length ? 'FAIL' : (flagged.length || nonGating) ? 'FLAG' : 'PASS'
|
||
|
||
// Attach machine-readable result for the matrix reporter.
|
||
await testInfo.attach('branded-result', {
|
||
contentType: 'application/json',
|
||
body: Buffer.from(JSON.stringify({
|
||
id: s.id, category: s.category, brand: s.brand, url: s.url, bp, posture: s.posture,
|
||
cellState, checks, flagged, unexpected: unexpected.map((u) => ({ name: u.name, detail: u.detail })),
|
||
staleFlags, accentNote: accNote, bodyForeign: p.bodyForeign,
|
||
rootKids: p.rootKids, bodyLen: p.bodyLen, bodySample: p.bodySample,
|
||
})),
|
||
})
|
||
|
||
// Console breadcrumb for the list reporter.
|
||
const glyph = cellState === 'PASS' ? '✓' : cellState === 'FLAG' ? '⚑' : '✗'
|
||
console.log(` ${glyph} ${s.id} @${bp} [${cellState}] ${checks.filter((c) => c.ok).length}/8 checks · ${accNote}` +
|
||
(flagged.length ? ` · FLAG: ${flagged.map((f) => f.check).join(',')}` : '') +
|
||
(unexpected.length ? ` · REGRESSION: ${unexpected.map((u) => `${u.name}(${u.detail})`).join(' ; ')}` : '') +
|
||
(staleFlags.length ? ` · STALE-FLAG(now passing): ${staleFlags.join(',')}` : '') +
|
||
(p.bodyForeign.length ? ` · body-ref: ${p.bodyForeign.join(',')}` : ''))
|
||
|
||
// ── The gate: only UNEXPECTED failures fail the run (documented flags don't) ─
|
||
expect(
|
||
unexpected,
|
||
`${s.id} @${bp} — REGRESSION in un-flagged check(s):\n` +
|
||
unexpected.map((u) => ` • ${u.name}: ${u.detail}`).join('\n') +
|
||
`\n(if this is a newly-accepted state, add a flag in e2e/branded/surfaces.ts with a flip condition)`,
|
||
).toEqual([])
|
||
})
|
||
})
|
||
}
|