Two of the five explorer brands pinned NEXT_PUBLIC_FONT_FAMILY to Geist off fonts.googleapis.com: every visitor to explore.lux.network and the SPC explorer was announced to Google on page load, and the two hosts rendered in a face the other three do not use. Dropping the pair leaves all five brands with the same env and the image's own faces. The branded matrix declared Basel Grotesk as the brand token for all four orgs, and the broker audit had already been switched to test for Zen while still calling the result hasGeist and printing it under a Geist label — a check whose name denied its own predicate. The README hero card asked for Inter.
593 lines
28 KiB
TypeScript
593 lines
28 KiB
TypeScript
import { test, expect, Page } from '@playwright/test'
|
|
import { httpStatus, snap } from './helpers'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Broker app URLs
|
|
// ---------------------------------------------------------------------------
|
|
const CLIENT = 'https://app.lux.financial'
|
|
const API = 'https://api.lux.financial'
|
|
const ADMIN = 'https://admin.lux.financial'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reachability cache — skip all broker tests if services are down
|
|
// ---------------------------------------------------------------------------
|
|
let _reachable: { client: boolean; admin: boolean; api: boolean } | null = null
|
|
|
|
async function checkBrokerReachability(request: import('@playwright/test').APIRequestContext) {
|
|
if (_reachable) return _reachable
|
|
const [clientStatus, adminStatus, apiStatus] = await Promise.all([
|
|
httpStatus(request, CLIENT, 15_000),
|
|
httpStatus(request, ADMIN, 15_000),
|
|
httpStatus(request, API, 15_000),
|
|
])
|
|
_reachable = {
|
|
client: clientStatus > 0 && clientStatus < 500,
|
|
admin: adminStatus > 0 && adminStatus < 500,
|
|
api: apiStatus > 0 && apiStatus < 500,
|
|
}
|
|
if (!_reachable.client) console.log(` WARN: app.lux.financial unreachable (HTTP ${clientStatus}) — skipping client tests`)
|
|
if (!_reachable.admin) console.log(` WARN: admin.lux.financial unreachable (HTTP ${adminStatus}) — skipping admin tests`)
|
|
if (!_reachable.api) console.log(` WARN: api.lux.financial unreachable (HTTP ${apiStatus}) — skipping API tests`)
|
|
return _reachable
|
|
}
|
|
|
|
function skipIfClientDown(request: import('@playwright/test').APIRequestContext) {
|
|
return checkBrokerReachability(request).then(r => {
|
|
if (!r.client) test.skip(true, 'app.lux.financial is unreachable')
|
|
})
|
|
}
|
|
|
|
function skipIfAdminDown(request: import('@playwright/test').APIRequestContext) {
|
|
return checkBrokerReachability(request).then(r => {
|
|
if (!r.admin) test.skip(true, 'admin.lux.financial is unreachable')
|
|
})
|
|
}
|
|
|
|
function skipIfApiDown(request: import('@playwright/test').APIRequestContext) {
|
|
return checkBrokerReachability(request).then(r => {
|
|
if (!r.api) test.skip(true, 'api.lux.financial is unreachable')
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Responsive viewports
|
|
// ---------------------------------------------------------------------------
|
|
const VIEWPORTS = {
|
|
mobile: { width: 375, height: 667 }, // iPhone SE
|
|
tablet: { width: 768, height: 1024 }, // iPad
|
|
laptop: { width: 1280, height: 800 }, // 13" laptop
|
|
desktop: { width: 1920, height: 1080 }, // 1080p
|
|
} as const
|
|
|
|
type ViewportName = keyof typeof VIEWPORTS
|
|
|
|
async function snapAt(page: Page, name: string, viewport: ViewportName) {
|
|
await page.setViewportSize(VIEWPORTS[viewport])
|
|
await page.waitForTimeout(1000)
|
|
await snap(page, `broker-${name}-${viewport}`)
|
|
}
|
|
|
|
async function snapAllViewports(page: Page, name: string) {
|
|
for (const vp of Object.keys(VIEWPORTS) as ViewportName[]) {
|
|
await snapAt(page, name, vp)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: detect modern @luxfi/ui design tokens
|
|
// ---------------------------------------------------------------------------
|
|
async function detectUIStack(page: Page) {
|
|
return page.evaluate(() => {
|
|
const style = getComputedStyle(document.documentElement)
|
|
|
|
// @luxfi/ui CSS custom properties
|
|
const hasTokens = style.getPropertyValue('--color-bg-primary').trim() !== '' ||
|
|
style.getPropertyValue('--radius').trim() !== ''
|
|
|
|
// Tailwind utility classes
|
|
const allEls = document.querySelectorAll('*')
|
|
let hasTailwind = false
|
|
for (const el of allEls) {
|
|
const cls = el.className
|
|
if (typeof cls === 'string' && (cls.includes('flex') || cls.includes('grid') || cls.includes('rounded') || cls.includes('bg-'))) {
|
|
hasTailwind = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// Zen face (used by @luxfi/ui)
|
|
const hasZen = document.fonts ? [...document.fonts].some(f => f.family.includes('Zen')) : false
|
|
|
|
// Modern input styling (bordered inputs vs plain text)
|
|
const inputs = document.querySelectorAll('input')
|
|
let hasBorderedInputs = false
|
|
for (const input of inputs) {
|
|
const s = getComputedStyle(input)
|
|
if (s.borderStyle !== 'none' && s.borderWidth !== '0px') {
|
|
hasBorderedInputs = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// Dark mode support
|
|
const html = document.documentElement
|
|
const hasDarkMode = html.classList.contains('dark') ||
|
|
html.getAttribute('data-theme') !== null ||
|
|
!!document.querySelector('[class*="dark:"]')
|
|
|
|
// reCAPTCHA status
|
|
const recaptchaError = !!document.querySelector('.rc-anchor-error-msg-container, [class*="recaptcha-error"]') ||
|
|
document.body?.textContent?.includes('ERROR for site owner') === true
|
|
|
|
return {
|
|
hasTokens,
|
|
hasTailwind,
|
|
hasZen,
|
|
hasBorderedInputs,
|
|
hasDarkMode,
|
|
recaptchaError,
|
|
fontFamily: style.fontFamily,
|
|
bgColor: style.backgroundColor,
|
|
color: style.color,
|
|
}
|
|
})
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Broker API — Health
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
test.describe('Broker API — Health', () => {
|
|
// api.lux.financial is a Hanzo Base app (bankd). Its canonical surface is
|
|
// /v1/* — `/`, /docs and /api/* are 404 BY DESIGN (Base does not serve them),
|
|
// so the health assertion targets /v1/health, and the money surface is proven
|
|
// by /v1/bank/* returning 401 (auth-guarded, i.e. mounted). Never assert /api/*.
|
|
test('api.lux.financial/v1/health returns 200', async ({ request }) => {
|
|
await skipIfApiDown(request)
|
|
const status = await httpStatus(request, `${API}/v1/health`, 15_000)
|
|
console.log(` API /v1/health: HTTP ${status}`)
|
|
expect(status).toBe(200)
|
|
})
|
|
|
|
test('api.lux.financial/healthz reachable', async ({ request }) => {
|
|
await skipIfApiDown(request)
|
|
const status = await httpStatus(request, `${API}/healthz`, 15_000)
|
|
console.log(` API /healthz: HTTP ${status}`)
|
|
expect(status).toBeGreaterThan(0)
|
|
})
|
|
|
|
test('api.lux.financial money surface (/v1/bank/*) is mounted and auth-guarded', async ({ request }) => {
|
|
await skipIfApiDown(request)
|
|
// Unauthenticated call to the banking API must be 401 — proves the routes
|
|
// are mounted and guarded (a healthy money surface), not 404/500.
|
|
const status = await httpStatus(request, `${API}/v1/bank/accounts/x/balances`, 15_000)
|
|
console.log(` API /v1/bank/*: HTTP ${status}`)
|
|
expect(status).toBe(401)
|
|
})
|
|
})
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Client App — Login
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
test.describe('Client App — Login', () => {
|
|
test('app.lux.financial loads with HTTP 200', async ({ page, request }) => {
|
|
await skipIfClientDown(request)
|
|
const res = await page.goto(CLIENT, { timeout: 30_000, waitUntil: 'domcontentloaded' })
|
|
expect(res?.status()).toBe(200)
|
|
await page.waitForTimeout(3000)
|
|
await snap(page, 'broker-client-home')
|
|
})
|
|
|
|
test('login page has email and password fields', async ({ page, request }) => {
|
|
await skipIfClientDown(request)
|
|
await page.goto(CLIENT, { timeout: 30_000, waitUntil: 'domcontentloaded' })
|
|
await page.waitForTimeout(3000)
|
|
|
|
// Detect login form elements flexibly (handles redesigns)
|
|
const emailInput = page.locator('input[type="email"], input[name="email"], input[placeholder*="email" i], input[autocomplete="email"]').first()
|
|
const passwordInput = page.locator('input[type="password"], input[name="password"], input[placeholder*="password" i], input[autocomplete*="password"]').first()
|
|
|
|
const hasEmail = await emailInput.isVisible({ timeout: 10_000 }).catch(() => false)
|
|
const hasPassword = await passwordInput.isVisible({ timeout: 5_000 }).catch(() => false)
|
|
|
|
// Check for any login/sign-in heading or button
|
|
const loginIndicator = page.locator('text=/Sign.?In/i, text=/Log.?In/i, text=/Login/i, text=/Welcome/i').first()
|
|
const hasIndicator = await loginIndicator.isVisible({ timeout: 5_000 }).catch(() => false)
|
|
|
|
console.log(` email visible: ${hasEmail}, password visible: ${hasPassword}, login indicator: ${hasIndicator}`)
|
|
// Page should have at least a login indicator or form fields
|
|
expect(hasEmail || hasPassword || hasIndicator).toBe(true)
|
|
|
|
await snap(page, 'broker-client-login-form')
|
|
})
|
|
|
|
test('forgot password link works', async ({ page, request }) => {
|
|
await skipIfClientDown(request)
|
|
await page.goto(CLIENT, { timeout: 30_000, waitUntil: 'domcontentloaded' })
|
|
await page.waitForTimeout(3000)
|
|
|
|
const forgotLink = page.locator('text=/Forgot.?Password/i, a[href*="forgot"]').first()
|
|
const visible = await forgotLink.isVisible({ timeout: 5_000 }).catch(() => false)
|
|
console.log(` Forgot password link visible: ${visible}`)
|
|
// Link may not exist on redesigned pages — log but accept
|
|
await snap(page, 'broker-client-forgot-link')
|
|
})
|
|
|
|
test('registration link works', async ({ page, request }) => {
|
|
await skipIfClientDown(request)
|
|
await page.goto(CLIENT, { timeout: 30_000, waitUntil: 'domcontentloaded' })
|
|
await page.waitForTimeout(3000)
|
|
|
|
const regLink = page.locator('a').filter({ hasText: /[Rr]egist|[Ss]ign.?[Uu]p|[Cc]reate.?[Aa]ccount/ }).first()
|
|
const visible = await regLink.isVisible({ timeout: 5_000 }).catch(() => false)
|
|
console.log(` Registration link visible: ${visible}`)
|
|
// Link may not exist on redesigned pages — log but accept
|
|
await snap(page, 'broker-client-register-link')
|
|
})
|
|
|
|
test('registration page loads', async ({ page, request }) => {
|
|
await skipIfClientDown(request)
|
|
const res = await page.goto(`${CLIENT}/registration`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
|
|
const status = res?.status() || 0
|
|
console.log(` Registration page HTTP: ${status}`)
|
|
// May be 200 or 404 if route was removed in redesign
|
|
expect(status).toBeGreaterThan(0)
|
|
await page.waitForTimeout(3000)
|
|
await snapAllViewports(page, 'client-registration')
|
|
})
|
|
|
|
test('forgot-password page loads', async ({ page, request }) => {
|
|
await skipIfClientDown(request)
|
|
const res = await page.goto(`${CLIENT}/forgot-password`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
|
|
const status = res?.status() || 0
|
|
console.log(` Forgot password page HTTP: ${status}`)
|
|
expect(status).toBeGreaterThan(0)
|
|
await page.waitForTimeout(2000)
|
|
await snapAllViewports(page, 'client-forgot-password')
|
|
})
|
|
|
|
test('2fa page loads', async ({ page, request }) => {
|
|
await skipIfClientDown(request)
|
|
const res = await page.goto(`${CLIENT}/2fa`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
|
|
const status = res?.status() || 0
|
|
console.log(` 2FA page HTTP: ${status}`)
|
|
expect(status).toBeGreaterThan(0)
|
|
await page.waitForTimeout(2000)
|
|
await snapAllViewports(page, 'client-2fa')
|
|
})
|
|
})
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Admin App — Login
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
test.describe('Admin App — Login', () => {
|
|
test('admin.lux.financial loads with HTTP 200', async ({ page, request }) => {
|
|
await skipIfAdminDown(request)
|
|
const res = await page.goto(ADMIN, { timeout: 30_000, waitUntil: 'domcontentloaded' })
|
|
expect(res?.status()).toBe(200)
|
|
await page.waitForTimeout(3000)
|
|
await snap(page, 'broker-admin-home')
|
|
})
|
|
|
|
test('admin login has email and password fields', async ({ page, request }) => {
|
|
await skipIfAdminDown(request)
|
|
await page.goto(ADMIN, { timeout: 30_000, waitUntil: 'domcontentloaded' })
|
|
await page.waitForTimeout(3000)
|
|
|
|
// Detect login form elements flexibly (handles redesigns)
|
|
const emailInput = page.locator('input[type="email"], input[name="email"], input[placeholder*="email" i], input[autocomplete="email"]').first()
|
|
const passwordInput = page.locator('input[type="password"], input[name="password"], input[placeholder*="password" i], input[autocomplete*="password"]').first()
|
|
|
|
const hasEmail = await emailInput.isVisible({ timeout: 10_000 }).catch(() => false)
|
|
const hasPassword = await passwordInput.isVisible({ timeout: 5_000 }).catch(() => false)
|
|
|
|
const loginIndicator = page.locator('text=/Sign.?In/i, text=/Log.?In/i, text=/Login/i, text=/Welcome/i').first()
|
|
const hasIndicator = await loginIndicator.isVisible({ timeout: 5_000 }).catch(() => false)
|
|
|
|
console.log(` email visible: ${hasEmail}, password visible: ${hasPassword}, login indicator: ${hasIndicator}`)
|
|
expect(hasEmail || hasPassword || hasIndicator).toBe(true)
|
|
|
|
await snap(page, 'broker-admin-login-form')
|
|
})
|
|
|
|
test('admin login redirects to /login when unauthenticated', async ({ page, request }) => {
|
|
await skipIfAdminDown(request)
|
|
await page.goto(`${ADMIN}/dashboard`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
|
|
await page.waitForTimeout(5000)
|
|
const currentUrl = page.url()
|
|
const isOnLogin = currentUrl.includes('/login') || currentUrl.includes('hanzo.id') ||
|
|
currentUrl.includes('lux.id') || currentUrl.startsWith(ADMIN)
|
|
console.log(` Admin dashboard redirect: ${currentUrl}`)
|
|
expect(isOnLogin).toBe(true)
|
|
await snap(page, 'broker-admin-dashboard-unauth')
|
|
})
|
|
})
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Client App — Responsive (all viewports)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
test.describe('Client App — Responsive', () => {
|
|
for (const [vpName, vpSize] of Object.entries(VIEWPORTS)) {
|
|
test(`client renders at ${vpName} (${vpSize.width}x${vpSize.height})`, async ({ page, request }) => {
|
|
await skipIfClientDown(request)
|
|
await page.setViewportSize(vpSize)
|
|
const res = await page.goto(CLIENT, { timeout: 30_000, waitUntil: 'domcontentloaded' })
|
|
expect(res?.status()).toBe(200)
|
|
await page.waitForTimeout(3000)
|
|
|
|
// Verify no horizontal overflow
|
|
const hasOverflow = await page.evaluate(() => {
|
|
return document.documentElement.scrollWidth > document.documentElement.clientWidth + 5
|
|
})
|
|
if (hasOverflow) {
|
|
console.log(` WARN: horizontal overflow at ${vpName}`)
|
|
}
|
|
|
|
// Verify content is visible
|
|
const bodyLen = (await page.textContent('body'))?.length || 0
|
|
expect(bodyLen).toBeGreaterThan(50)
|
|
|
|
// Check text isn't clipped off-screen on mobile
|
|
if (vpName === 'mobile') {
|
|
const headingClipped = await page.evaluate(() => {
|
|
const h1 = document.querySelector('h1, h2, [class*="title"], [class*="heading"]')
|
|
if (!h1) return false
|
|
const rect = h1.getBoundingClientRect()
|
|
return rect.left < 0
|
|
})
|
|
if (headingClipped) {
|
|
console.log(` WARN: heading clipped off-screen at mobile`)
|
|
}
|
|
}
|
|
|
|
await snap(page, `broker-client-${vpName}`)
|
|
})
|
|
}
|
|
})
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Admin App — Responsive (all viewports)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
test.describe('Admin App — Responsive', () => {
|
|
for (const [vpName, vpSize] of Object.entries(VIEWPORTS)) {
|
|
test(`admin renders at ${vpName} (${vpSize.width}x${vpSize.height})`, async ({ page, request }) => {
|
|
await skipIfAdminDown(request)
|
|
await page.setViewportSize(vpSize)
|
|
const res = await page.goto(ADMIN, { timeout: 30_000, waitUntil: 'domcontentloaded' })
|
|
expect(res?.status()).toBe(200)
|
|
await page.waitForTimeout(3000)
|
|
|
|
const hasOverflow = await page.evaluate(() => {
|
|
return document.documentElement.scrollWidth > document.documentElement.clientWidth + 5
|
|
})
|
|
if (hasOverflow) {
|
|
console.log(` WARN: horizontal overflow at ${vpName}`)
|
|
}
|
|
|
|
const bodyLen = (await page.textContent('body'))?.length || 0
|
|
expect(bodyLen).toBeGreaterThan(50)
|
|
|
|
await snap(page, `broker-admin-${vpName}`)
|
|
})
|
|
}
|
|
})
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Client App — Public Screens (no auth required)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
test.describe('Client App — Public Screens', () => {
|
|
const PUBLIC_ROUTES = [
|
|
{ path: '/', name: 'home' },
|
|
{ path: '/registration', name: 'registration' },
|
|
// /registration/success needs prior registration state — tested via flow, not direct nav
|
|
{ path: '/forgot-password', name: 'forgot-password' },
|
|
{ path: '/2fa', name: '2fa' },
|
|
]
|
|
|
|
for (const route of PUBLIC_ROUTES) {
|
|
test(`client ${route.name} page loads (${route.path})`, async ({ page, request }) => {
|
|
await skipIfClientDown(request)
|
|
const res = await page.goto(`${CLIENT}${route.path}`, {
|
|
timeout: 30_000,
|
|
waitUntil: 'domcontentloaded',
|
|
})
|
|
const status = res?.status() || 0
|
|
console.log(` ${route.path}: HTTP ${status}`)
|
|
expect(status).toBeGreaterThan(0)
|
|
await page.waitForTimeout(2000)
|
|
await snapAllViewports(page, `client-${route.name}`)
|
|
})
|
|
}
|
|
})
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Client App — Auth-Gated Screens (expect 500 without session)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
test.describe('Client App — Auth-Gated Screens', () => {
|
|
const AUTH_ROUTES = [
|
|
{ path: '/dashboard', name: 'dashboard' },
|
|
{ path: '/wallet', name: 'wallet' },
|
|
{ path: '/clients', name: 'clients' },
|
|
{ path: '/beneficiaries', name: 'beneficiaries' },
|
|
{ path: '/create-payment', name: 'create-payment' },
|
|
{ path: '/conversion', name: 'conversion' },
|
|
{ path: '/in-approval', name: 'in-approval' },
|
|
{ path: '/transactions', name: 'transactions' },
|
|
{ path: '/users', name: 'users' },
|
|
{ path: '/profile', name: 'profile' },
|
|
{ path: '/manage-account', name: 'manage-account' },
|
|
{ path: '/documents', name: 'documents' },
|
|
{ path: '/about', name: 'about' },
|
|
]
|
|
|
|
for (const route of AUTH_ROUTES) {
|
|
test(`client ${route.name} is auth-gated (${route.path})`, async ({ page, request }) => {
|
|
await skipIfClientDown(request)
|
|
const res = await page.goto(`${CLIENT}${route.path}`, {
|
|
timeout: 30_000,
|
|
waitUntil: 'domcontentloaded',
|
|
})
|
|
const status = res?.status() || 0
|
|
console.log(` ${route.path}: HTTP ${status}`)
|
|
// Auth-gated pages return 500 (RSC error without session) or redirect to login
|
|
expect([200, 302, 307, 500]).toContain(status)
|
|
await page.waitForTimeout(2000)
|
|
await snap(page, `broker-client-${route.name}-unauth`)
|
|
})
|
|
}
|
|
})
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Admin App — Core Screens (SPA, all return 200)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
test.describe('Admin App — Core Screens', () => {
|
|
const ADMIN_ROUTES = [
|
|
{ path: '/', name: 'home' },
|
|
{ path: '/dashboard', name: 'dashboard' },
|
|
{ path: '/users', name: 'users' },
|
|
{ path: '/accounts', name: 'accounts' },
|
|
{ path: '/transfers', name: 'transfers' },
|
|
{ path: '/kyc', name: 'kyc' },
|
|
{ path: '/compliance', name: 'compliance' },
|
|
{ path: '/settings', name: 'settings' },
|
|
{ path: '/audit', name: 'audit' },
|
|
{ path: '/funding', name: 'funding' },
|
|
]
|
|
|
|
for (const route of ADMIN_ROUTES) {
|
|
test(`admin ${route.name} page loads (${route.path})`, async ({ page, request }) => {
|
|
await skipIfAdminDown(request)
|
|
const res = await page.goto(`${ADMIN}${route.path}`, {
|
|
timeout: 30_000,
|
|
waitUntil: 'domcontentloaded',
|
|
})
|
|
const status = res?.status() || 0
|
|
console.log(` ${route.path}: HTTP ${status}`)
|
|
expect(status).toBe(200)
|
|
await page.waitForTimeout(2000)
|
|
await snapAllViewports(page, `admin-${route.name}`)
|
|
})
|
|
}
|
|
})
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// UI Stack — @luxfi/ui Verification
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
test.describe('UI Stack — @luxfi/ui Verification', () => {
|
|
test('client app UI stack audit', async ({ page, request }) => {
|
|
await skipIfClientDown(request)
|
|
await page.goto(CLIENT, { timeout: 30_000, waitUntil: 'domcontentloaded' })
|
|
await page.waitForTimeout(3000)
|
|
|
|
const ui = await detectUIStack(page)
|
|
console.log(` Client UI audit:`)
|
|
console.log(` @luxfi/ui tokens: ${ui.hasTokens}`)
|
|
console.log(` Tailwind classes: ${ui.hasTailwind}`)
|
|
console.log(` Zen font: ${ui.hasZen}`)
|
|
console.log(` Bordered inputs: ${ui.hasBorderedInputs}`)
|
|
console.log(` Dark mode: ${ui.hasDarkMode}`)
|
|
console.log(` reCAPTCHA error: ${ui.recaptchaError}`)
|
|
console.log(` Font: ${ui.fontFamily.slice(0, 80)}`)
|
|
console.log(` BG color: ${ui.bgColor}`)
|
|
|
|
if (ui.recaptchaError) {
|
|
console.log(` ⚠ reCAPTCHA "Invalid site key" — needs NEXT_PUBLIC_RECAPTCHA_SITE_KEY`)
|
|
}
|
|
|
|
await snapAllViewports(page, 'client-ui-audit')
|
|
})
|
|
|
|
test('admin app UI stack audit', async ({ page, request }) => {
|
|
await skipIfAdminDown(request)
|
|
await page.goto(ADMIN, { timeout: 30_000, waitUntil: 'domcontentloaded' })
|
|
await page.waitForTimeout(3000)
|
|
|
|
const ui = await detectUIStack(page)
|
|
console.log(` Admin UI audit:`)
|
|
console.log(` @luxfi/ui tokens: ${ui.hasTokens}`)
|
|
console.log(` Tailwind classes: ${ui.hasTailwind}`)
|
|
console.log(` Zen font: ${ui.hasZen}`)
|
|
console.log(` Bordered inputs: ${ui.hasBorderedInputs}`)
|
|
console.log(` Dark mode: ${ui.hasDarkMode}`)
|
|
console.log(` reCAPTCHA error: ${ui.recaptchaError}`)
|
|
console.log(` Font: ${ui.fontFamily.slice(0, 80)}`)
|
|
console.log(` BG color: ${ui.bgColor}`)
|
|
|
|
if (ui.recaptchaError) {
|
|
console.log(` ⚠ reCAPTCHA "Invalid site key" — needs NEXT_PUBLIC_RECAPTCHA_SITE_KEY`)
|
|
}
|
|
|
|
await snapAllViewports(page, 'admin-ui-audit')
|
|
})
|
|
|
|
test('client vs admin style consistency', async ({ page, request }) => {
|
|
await skipIfClientDown(request)
|
|
await skipIfAdminDown(request)
|
|
// Capture client styles
|
|
await page.goto(CLIENT, { timeout: 30_000, waitUntil: 'domcontentloaded' })
|
|
await page.waitForTimeout(3000)
|
|
const clientUI = await detectUIStack(page)
|
|
|
|
// Capture admin styles
|
|
await page.goto(ADMIN, { timeout: 30_000, waitUntil: 'domcontentloaded' })
|
|
await page.waitForTimeout(3000)
|
|
const adminUI = await detectUIStack(page)
|
|
|
|
const clientFont = clientUI.fontFamily.split(',')[0].trim().replace(/['"]/g, '')
|
|
const adminFont = adminUI.fontFamily.split(',')[0].trim().replace(/['"]/g, '')
|
|
|
|
console.log(` Style comparison:`)
|
|
console.log(` Client font: "${clientFont}"`)
|
|
console.log(` Admin font: "${adminFont}"`)
|
|
console.log(` Fonts match: ${clientFont === adminFont}`)
|
|
console.log(` Client bg: ${clientUI.bgColor}`)
|
|
console.log(` Admin bg: ${adminUI.bgColor}`)
|
|
console.log(` Client tokens: ${clientUI.hasTokens} Admin tokens: ${adminUI.hasTokens}`)
|
|
console.log(` Client TW: ${clientUI.hasTailwind} Admin TW: ${adminUI.hasTailwind}`)
|
|
console.log(` Client inputs: ${clientUI.hasBorderedInputs} Admin inputs: ${adminUI.hasBorderedInputs}`)
|
|
|
|
if (!clientUI.hasBorderedInputs && adminUI.hasBorderedInputs) {
|
|
console.log(` ⚠ Client app has unstyled/plain inputs — needs @luxfi/ui Input component`)
|
|
}
|
|
if (!clientUI.hasTailwind && adminUI.hasTailwind) {
|
|
console.log(` ⚠ Client app missing Tailwind — needs @luxfi/ui migration`)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Navigation & Meta
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
test.describe('Meta & Accessibility', () => {
|
|
test('client has proper meta tags', async ({ page, request }) => {
|
|
await skipIfClientDown(request)
|
|
await page.goto(CLIENT, { timeout: 30_000, waitUntil: 'domcontentloaded' })
|
|
await page.waitForTimeout(2000)
|
|
|
|
const title = await page.title()
|
|
console.log(` Client title: ${title}`)
|
|
expect(title).toBeTruthy()
|
|
|
|
const viewport = await page.evaluate(() => {
|
|
return document.querySelector('meta[name="viewport"]')?.getAttribute('content') || ''
|
|
})
|
|
console.log(` Viewport meta: ${viewport}`)
|
|
expect(viewport).toContain('width=')
|
|
})
|
|
|
|
test('admin has proper meta tags', async ({ page, request }) => {
|
|
await skipIfAdminDown(request)
|
|
await page.goto(ADMIN, { timeout: 30_000, waitUntil: 'domcontentloaded' })
|
|
await page.waitForTimeout(2000)
|
|
|
|
const title = await page.title()
|
|
console.log(` Admin title: ${title}`)
|
|
expect(title).toBeTruthy()
|
|
|
|
const viewport = await page.evaluate(() => {
|
|
return document.querySelector('meta[name="viewport"]')?.getAttribute('content') || ''
|
|
})
|
|
console.log(` Viewport meta: ${viewport}`)
|
|
expect(viewport).toContain('width=')
|
|
})
|
|
})
|