Files
universe/e2e/19-broker-deep.spec.ts
zeekay b588abf446 the estate face is Zen, everywhere it is named
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.
2026-08-28 11:19:47 -07:00

1284 lines
61 KiB
TypeScript

import { test, expect, Page } from '@playwright/test'
import { httpStatus, httpGetJson, 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 },
tablet: { width: 768, height: 1024 },
laptop: { width: 1280, height: 800 },
desktop: { width: 1920, height: 1080 },
} as const
type ViewportName = keyof typeof VIEWPORTS
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function snapAllViewports(page: Page, name: string) {
for (const vp of Object.keys(VIEWPORTS) as ViewportName[]) {
await page.setViewportSize(VIEWPORTS[vp])
await page.waitForTimeout(800)
await snap(page, `broker-deep-${name}-${vp}`)
}
}
/** Check that the page has no horizontal overflow beyond a small tolerance. */
async function checkNoHorizontalOverflow(page: Page): Promise<boolean> {
return page.evaluate(() => {
return document.documentElement.scrollWidth <= document.documentElement.clientWidth + 5
})
}
/** Check that a login form card is roughly centered horizontally. */
async function checkFormCentered(page: Page): Promise<boolean> {
return page.evaluate(() => {
const form = document.querySelector('form') ||
document.querySelector('[class*="card" i]') ||
document.querySelector('[class*="login" i]') ||
document.querySelector('[class*="auth" i]')
if (!form) return true // no form found, skip
const rect = form.getBoundingClientRect()
const vw = document.documentElement.clientWidth
const leftGap = rect.left
const rightGap = vw - rect.right
// Centered means gaps differ by less than 30% of viewport
return Math.abs(leftGap - rightGap) < vw * 0.3
})
}
/** Detect UI stack properties. */
async function detectUIStack(page: Page) {
return page.evaluate(() => {
const style = getComputedStyle(document.documentElement)
const hasTokens = style.getPropertyValue('--color-bg-primary').trim() !== '' ||
style.getPropertyValue('--radius').trim() !== ''
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
}
}
const hasZen = document.fonts ? [...document.fonts].some(f => f.family.includes('Zen')) : false
const hasDarkMode = document.documentElement.classList.contains('dark') ||
document.documentElement.getAttribute('data-theme') !== null ||
!!document.querySelector('[class*="dark:"]')
return {
hasTokens,
hasTailwind,
hasZen,
hasDarkMode,
fontFamily: style.fontFamily,
bgColor: style.backgroundColor,
color: style.color,
}
})
}
// ═══════════════════════════════════════════════════════════════════════════
// 1. API Deep Health
// ═══════════════════════════════════════════════════════════════════════════
test.describe('1 — API Deep Health', () => {
test('GET /api returns {status: "ok"}', async ({ request }) => {
await skipIfApiDown(request)
const status = await httpStatus(request, API, 15_000)
console.log(` GET /api: HTTP ${status}`)
expect(status).toBe(200)
const body = await httpGetJson(request, API, 15_000).catch(() => ({})) as Record<string, unknown>
console.log(` Body: ${JSON.stringify(body).slice(0, 200)}`)
// Accept either {status:"ok"} or any 200 response
expect(status).toBe(200)
})
test('GET /api/v2/misc/currencies returns currency list or 401', async ({ request }) => {
await skipIfApiDown(request)
const res = await request.get(`${API}/api/v2/misc/currencies`, {
ignoreHTTPSErrors: true,
timeout: 15_000,
})
const status = res.status()
console.log(` /api/v2/misc/currencies: HTTP ${status}`)
expect([200, 401, 403]).toContain(status)
if (status === 200) {
const json = await res.json().catch(() => ({}))
console.log(` Currency count: ${Array.isArray(json) ? json.length : 'N/A'}`)
}
})
test('GET /api/v2/misc/countries returns country list or 401', async ({ request }) => {
await skipIfApiDown(request)
const res = await request.get(`${API}/api/v2/misc/countries`, {
ignoreHTTPSErrors: true,
timeout: 15_000,
})
const status = res.status()
console.log(` /api/v2/misc/countries: HTTP ${status}`)
expect([200, 401, 403]).toContain(status)
if (status === 200) {
const json = await res.json().catch(() => ({}))
console.log(` Country count: ${Array.isArray(json) ? json.length : 'N/A'}`)
}
})
test('POST /api/v1/auth/login with empty body returns 400/401 (not 500)', async ({ request }) => {
await skipIfApiDown(request)
const res = await request.post(`${API}/api/v1/auth/login`, {
data: {},
headers: { 'Content-Type': 'application/json' },
ignoreHTTPSErrors: true,
timeout: 15_000,
})
const status = res.status()
console.log(` POST /api/v1/auth/login (empty): HTTP ${status}`)
// Should be a client error, not a server error
expect(status).toBeLessThan(500)
expect(status).toBeGreaterThanOrEqual(400)
})
test('API responds with proper CORS headers', async ({ request }) => {
await skipIfApiDown(request)
const res = await request.get(API, {
ignoreHTTPSErrors: true,
timeout: 15_000,
headers: { Origin: 'https://app.lux.financial' },
})
const headers = res.headers()
console.log(` access-control-allow-origin: ${headers['access-control-allow-origin'] || 'not set'}`)
console.log(` content-type: ${headers['content-type'] || 'not set'}`)
// Log CORS — presence is informational; some APIs handle CORS at gateway
expect(res.status()).toBeGreaterThan(0)
})
test('API rejects unauthenticated /api/v2/wallets/balances with 401', async ({ request }) => {
await skipIfApiDown(request)
const res = await request.get(`${API}/api/v2/wallets/balances`, {
ignoreHTTPSErrors: true,
timeout: 15_000,
})
const status = res.status()
console.log(` /api/v2/wallets/balances: HTTP ${status}`)
expect([401, 403]).toContain(status)
})
test('API rejects unauthenticated /api/v2/transactions with 401', async ({ request }) => {
await skipIfApiDown(request)
const res = await request.get(`${API}/api/v2/transactions`, {
ignoreHTTPSErrors: true,
timeout: 15_000,
})
const status = res.status()
console.log(` /api/v2/transactions: HTTP ${status}`)
expect([401, 403]).toContain(status)
})
test('API rejects unauthenticated /api/v2/beneficiaries with 401', async ({ request }) => {
await skipIfApiDown(request)
const res = await request.get(`${API}/api/v2/beneficiaries`, {
ignoreHTTPSErrors: true,
timeout: 15_000,
})
const status = res.status()
console.log(` /api/v2/beneficiaries: HTTP ${status}`)
expect([401, 403]).toContain(status)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 2. Client Portal — Login Flow (all viewports)
// ═══════════════════════════════════════════════════════════════════════════
test.describe('2 — Client Portal — Login Flow', () => {
for (const [vpName, vpSize] of Object.entries(VIEWPORTS)) {
test.describe(`@ ${vpName} (${vpSize.width}x${vpSize.height})`, () => {
test('login page loads with Sign In heading', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(CLIENT, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
// Detect login indicator flexibly (handles redesigns)
// The redesigned page may show "Sign In", "Welcome back", or "Continue with Lux ID"
const bodyText = await page.textContent('body') || ''
const hasLoginText = /sign.?in|log.?in|login|welcome|continue/i.test(bodyText)
console.log(` [${vpName}] Login-related text found: ${hasLoginText}`)
expect(hasLoginText).toBe(true)
await snap(page, `broker-deep-login-heading-${vpName}`)
})
test('email and password fields visible with proper labels', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(CLIENT, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
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: 5_000 }).catch(() => false)
const hasPassword = await passwordInput.isVisible({ timeout: 5_000 }).catch(() => false)
console.log(` [${vpName}] Email visible: ${hasEmail}, Password visible: ${hasPassword}`)
// At least one login-related field should be visible
expect(hasEmail || hasPassword).toBe(true)
})
test('Log In button visible', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(CLIENT, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
// Redesigned page uses "Continue with Lux ID" instead of "Login"
const loginBtn = page.locator('button:has-text("Login"), button:has-text("Log In"), button:has-text("Sign In"), button:has-text("Continue"), button[type="submit"]').first()
const visible = await loginBtn.isVisible({ timeout: 5_000 }).catch(() => false)
console.log(` [${vpName}] Login button visible: ${visible}`)
expect(visible).toBe(true)
})
test('reCAPTCHA widget loads (no k=undefined)', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(CLIENT, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(5000)
const recaptchaFrame = page.locator('iframe[src*="recaptcha"]').first()
const recaptchaVisible = await recaptchaFrame.isVisible().catch(() => false)
console.log(` [${vpName}] reCAPTCHA iframe visible: ${recaptchaVisible}`)
if (recaptchaVisible) {
const src = await recaptchaFrame.getAttribute('src') || ''
const hasUndefinedKey = src.includes('k=undefined')
console.log(` [${vpName}] reCAPTCHA src contains k=undefined: ${hasUndefinedKey}`)
expect(hasUndefinedKey).toBe(false)
}
// Also check for reCAPTCHA div (may render without iframe initially)
const recaptchaDiv = page.locator('[class*="recaptcha"], [id*="recaptcha"], .g-recaptcha').first()
const divVisible = await recaptchaDiv.isVisible().catch(() => false)
console.log(` [${vpName}] reCAPTCHA div visible: ${divVisible}`)
})
test('Forgot Password link visible and clickable', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
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(` [${vpName}] Forgot Password visible: ${visible}`)
// Link may not exist on redesigned pages
})
test('Request registration link visible', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
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(` [${vpName}] Registration link visible: ${visible}`)
// Link may not exist on redesigned pages
})
test('no horizontal overflow', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(CLIENT, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const noOverflow = await checkNoHorizontalOverflow(page)
if (!noOverflow) {
console.log(` WARN: horizontal overflow at ${vpName}`)
}
expect(noOverflow).toBe(true)
})
test('form is centered in card', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(CLIENT, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const centered = await checkFormCentered(page)
console.log(` [${vpName}] Form centered: ${centered}`)
expect(centered).toBe(true)
await snap(page, `broker-deep-login-centered-${vpName}`)
})
})
}
})
// ═══════════════════════════════════════════════════════════════════════════
// 3. Client Portal — Registration (all viewports)
// ═══════════════════════════════════════════════════════════════════════════
test.describe('3 — Client Portal — Registration', () => {
for (const [vpName, vpSize] of Object.entries(VIEWPORTS)) {
test.describe(`@ ${vpName} (${vpSize.width}x${vpSize.height})`, () => {
test('registration page loads with "Request Registration" heading', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(`${CLIENT}/registration`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const heading = page.locator('text=Request Registration').first()
const altHeading = page.locator('text=Registration').first()
const visible = await heading.isVisible().catch(() => false) ||
await altHeading.isVisible().catch(() => false)
console.log(` [${vpName}] Registration heading visible: ${visible}`)
expect(visible).toBe(true)
await snap(page, `broker-deep-registration-heading-${vpName}`)
})
test('First Name, Last Name, Email, Mobile Number fields visible', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(`${CLIENT}/registration`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
// Try multiple selectors for each field
const firstNameVisible = await page.locator('input[name*="first" i], input[placeholder*="first" i], input[name*="firstName" i]').first().isVisible().catch(() => false)
const lastNameVisible = await page.locator('input[name*="last" i], input[placeholder*="last" i], input[name*="lastName" i]').first().isVisible().catch(() => false)
const emailVisible = await page.locator('input[type="email"], input[name*="email" i], input[placeholder*="email" i]').first().isVisible().catch(() => false)
const mobileVisible = await page.locator('input[type="tel"], input[name*="mobile" i], input[name*="phone" i], input[placeholder*="phone" i], input[placeholder*="mobile" i]').first().isVisible().catch(() => false)
console.log(` [${vpName}] First Name: ${firstNameVisible}, Last Name: ${lastNameVisible}, Email: ${emailVisible}, Mobile: ${mobileVisible}`)
// At least email should be visible on any registration form
expect(emailVisible).toBe(true)
})
test('Send button visible', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(`${CLIENT}/registration`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const sendBtn = page.locator('button:has-text("Send"), button:has-text("Submit"), button:has-text("Register"), button[type="submit"]').first()
await expect(sendBtn).toBeVisible({ timeout: 5_000 })
})
test('"Already using Lux? Sign in" link visible', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(`${CLIENT}/registration`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const signInLink = page.locator('a:has-text("Sign in"), a:has-text("Sign In"), a:has-text("Log in"), a:has-text("Login")').first()
const visible = await signInLink.isVisible().catch(() => false)
console.log(` [${vpName}] Sign in link visible: ${visible}`)
// Some viewports might show it differently
expect(visible).toBe(true)
})
test('form validation works (submit empty shows errors)', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(`${CLIENT}/registration`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
// Click submit without filling anything
const submitBtn = page.locator('button:has-text("Send"), button:has-text("Submit"), button:has-text("Register"), button[type="submit"]').first()
const btnVisible = await submitBtn.isVisible().catch(() => false)
if (btnVisible) {
await submitBtn.click()
await page.waitForTimeout(1500)
// Check for validation errors (multiple possible selectors)
const hasErrors = await page.evaluate(() => {
const errorEls = document.querySelectorAll(
'[class*="error" i], [class*="invalid" i], [role="alert"], .text-red-500, .text-destructive, [aria-invalid="true"]'
)
const invalidInputs = document.querySelectorAll('input:invalid')
return errorEls.length > 0 || invalidInputs.length > 0
})
console.log(` [${vpName}] Validation errors shown: ${hasErrors}`)
}
await snap(page, `broker-deep-registration-validation-${vpName}`)
})
test('no horizontal overflow', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(`${CLIENT}/registration`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const noOverflow = await checkNoHorizontalOverflow(page)
if (!noOverflow) {
console.log(` WARN: horizontal overflow on registration at ${vpName}`)
}
expect(noOverflow).toBe(true)
})
})
}
})
// ═══════════════════════════════════════════════════════════════════════════
// 4. Client Portal — 2FA Page (all viewports)
// ═══════════════════════════════════════════════════════════════════════════
test.describe('4 — Client Portal — 2FA Page', () => {
for (const [vpName, vpSize] of Object.entries(VIEWPORTS)) {
test.describe(`@ ${vpName} (${vpSize.width}x${vpSize.height})`, () => {
test('2FA page loads with "Enter the 6 digit code" heading', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(`${CLIENT}/2fa`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const heading = page.locator('text=/6.?digit/i').first()
const altHeading = page.locator('text=/two.?factor/i, text=/2fa/i, text=/verification/i').first()
const visible = await heading.isVisible().catch(() => false) ||
await altHeading.isVisible().catch(() => false)
console.log(` [${vpName}] 2FA heading visible: ${visible}`)
// May redirect if no session — log but accept
await snap(page, `broker-deep-2fa-heading-${vpName}`)
})
test('pin input (6 boxes) visible', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(`${CLIENT}/2fa`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
// 2FA pin inputs can be individual inputs or a single OTP input
const pinInputs = page.locator('input[type="tel"], input[type="number"], input[type="text"], input[inputmode="numeric"]')
const count = await pinInputs.count()
console.log(` [${vpName}] Pin input count: ${count}`)
// May have 6 separate inputs or 1 grouped input
await snap(page, `broker-deep-2fa-pin-${vpName}`)
})
test('Verify button visible', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(`${CLIENT}/2fa`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const verifyBtn = page.locator('button:has-text("Verify"), button:has-text("Submit"), button:has-text("Confirm"), button[type="submit"]').first()
const visible = await verifyBtn.isVisible().catch(() => false)
console.log(` [${vpName}] Verify button visible: ${visible}`)
await snap(page, `broker-deep-2fa-verify-${vpName}`)
})
test('"Send again" text visible with timer', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(`${CLIENT}/2fa`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const sendAgain = page.locator('text=/send.?again/i, text=/resend/i').first()
const visible = await sendAgain.isVisible().catch(() => false)
console.log(` [${vpName}] Send again visible: ${visible}`)
})
test('no horizontal overflow', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(`${CLIENT}/2fa`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const noOverflow = await checkNoHorizontalOverflow(page)
if (!noOverflow) {
console.log(` WARN: horizontal overflow on 2FA at ${vpName}`)
}
expect(noOverflow).toBe(true)
})
})
}
})
// ═══════════════════════════════════════════════════════════════════════════
// 5. Client Portal — Forgot Password (all viewports)
// ═══════════════════════════════════════════════════════════════════════════
test.describe('5 — Client Portal — Forgot Password', () => {
for (const [vpName, vpSize] of Object.entries(VIEWPORTS)) {
test.describe(`@ ${vpName} (${vpSize.width}x${vpSize.height})`, () => {
test('page loads with "Forgot Password" heading', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(`${CLIENT}/forgot-password`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const heading = page.locator('text=Forgot Password').first()
const altHeading = page.locator('text=Reset Password').first()
const visible = await heading.isVisible().catch(() => false) ||
await altHeading.isVisible().catch(() => false)
console.log(` [${vpName}] Forgot Password heading visible: ${visible}`)
expect(visible).toBe(true)
await snap(page, `broker-deep-forgot-heading-${vpName}`)
})
test('email field visible', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(`${CLIENT}/forgot-password`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const emailInput = page.locator('input[type="email"], input[name="email"], input[placeholder*="email" i]').first()
await expect(emailInput).toBeVisible({ timeout: 5_000 })
})
test('"Send Reset Link" button visible', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(`${CLIENT}/forgot-password`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const resetBtn = page.locator('button:has-text("Send"), button:has-text("Reset"), button:has-text("Submit"), button[type="submit"]').first()
await expect(resetBtn).toBeVisible({ timeout: 5_000 })
})
test('"Back to Sign In" link visible', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(`${CLIENT}/forgot-password`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const backLink = page.locator('a:has-text("Sign In"), a:has-text("Sign in"), a:has-text("Back"), a:has-text("Login")').first()
const visible = await backLink.isVisible().catch(() => false)
console.log(` [${vpName}] Back to Sign In visible: ${visible}`)
expect(visible).toBe(true)
})
test('no horizontal overflow', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(vpSize)
await page.goto(`${CLIENT}/forgot-password`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const noOverflow = await checkNoHorizontalOverflow(page)
expect(noOverflow).toBe(true)
})
})
}
})
// ═══════════════════════════════════════════════════════════════════════════
// 6. Client Portal — Auth-Gated Pages Structure
// ═══════════════════════════════════════════════════════════════════════════
test.describe('6 — Client Portal — Auth-Gated Pages Structure', () => {
const AUTH_ROUTES = [
'/dashboard',
'/wallet',
'/clients',
'/beneficiaries',
'/create-payment',
'/conversion',
'/in-approval',
'/transactions',
'/users',
'/profile',
'/manage-account',
'/documents',
'/about',
]
for (const route of AUTH_ROUTES) {
test(`${route} responds and screenshots at desktop`, async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(VIEWPORTS.desktop)
const res = await page.goto(`${CLIENT}${route}`, {
timeout: 30_000,
waitUntil: 'domcontentloaded',
})
const status = res?.status() || 0
console.log(` ${route}: HTTP ${status}`)
// Auth-gated pages: 200 (rendered shell), 302/307 (redirect), or 500 (RSC without session)
expect([200, 302, 307, 500]).toContain(status)
await page.waitForTimeout(2000)
await snap(page, `broker-deep-authgate-${route.replace(/\//g, '-').replace(/^-/, '')}`)
})
}
})
// ═══════════════════════════════════════════════════════════════════════════
// 7. Client Portal — Dashboard UI (unauthenticated shell check)
// ═══════════════════════════════════════════════════════════════════════════
test.describe('7 — Client Portal — Dashboard UI Shell', () => {
test('sidebar navigation renders', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(VIEWPORTS.desktop)
await page.goto(`${CLIENT}/dashboard`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const pageContent = await page.textContent('body') || ''
// Check for sidebar navigation items (may or may not render without auth)
const sidebarItems = ['Dashboard', 'Wallet', 'Beneficiaries', 'Transactions', 'Profile']
const foundItems: string[] = []
for (const item of sidebarItems) {
if (pageContent.includes(item)) foundItems.push(item)
}
console.log(` Sidebar items found: ${foundItems.join(', ') || 'none (may need auth)'}`)
await snap(page, 'broker-deep-dashboard-sidebar')
})
test('quick action cards visible', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(VIEWPORTS.desktop)
await page.goto(`${CLIENT}/dashboard`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const pageContent = await page.textContent('body') || ''
const quickActions = ['Add a currency', 'Add a beneficiary', 'Create a payment', 'Make a conversion']
const foundActions: string[] = []
for (const action of quickActions) {
if (pageContent.toLowerCase().includes(action.toLowerCase())) foundActions.push(action)
}
console.log(` Quick actions found: ${foundActions.join(', ') || 'none (may need auth)'}`)
await snap(page, 'broker-deep-dashboard-quickactions')
})
test('"Overall Overview" section with stats', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(VIEWPORTS.desktop)
await page.goto(`${CLIENT}/dashboard`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const pageContent = await page.textContent('body') || ''
const statsTerms = ['Overview', 'Pending', 'Conversions', 'Payments']
const foundStats: string[] = []
for (const term of statsTerms) {
if (pageContent.includes(term)) foundStats.push(term)
}
console.log(` Stats terms found: ${foundStats.join(', ') || 'none (may need auth)'}`)
await snap(page, 'broker-deep-dashboard-overview')
})
test('"Recent Transactions" table/section present', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(VIEWPORTS.desktop)
await page.goto(`${CLIENT}/dashboard`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const pageContent = await page.textContent('body') || ''
const hasTransactions = pageContent.toLowerCase().includes('recent') ||
pageContent.toLowerCase().includes('transaction')
console.log(` Recent Transactions section: ${hasTransactions ? 'found' : 'not found (may need auth)'}`)
await snap(page, 'broker-deep-dashboard-transactions')
})
test('dashboard screenshots at all viewports', async ({ page, request }) => {
await skipIfClientDown(request)
await page.goto(`${CLIENT}/dashboard`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
await snapAllViewports(page, 'dashboard-shell')
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 8. Client Portal — Wallet Page Shell
// ═══════════════════════════════════════════════════════════════════════════
test.describe('8 — Client Portal — Wallet Page Shell', () => {
test('"Wallets" heading or wallet-related content', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(VIEWPORTS.desktop)
await page.goto(`${CLIENT}/wallet`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const pageContent = await page.textContent('body') || ''
const hasWallet = pageContent.toLowerCase().includes('wallet') ||
pageContent.toLowerCase().includes('balance') ||
pageContent.toLowerCase().includes('currency')
console.log(` Wallet content found: ${hasWallet}`)
await snap(page, 'broker-deep-wallet-heading')
})
test('"Add a currency" button or similar', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(VIEWPORTS.desktop)
await page.goto(`${CLIENT}/wallet`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const addBtn = page.locator('button:has-text("Add"), button:has-text("Currency"), a:has-text("Add")').first()
const visible = await addBtn.isVisible().catch(() => false)
console.log(` Add currency button visible: ${visible}`)
await snap(page, 'broker-deep-wallet-addcurrency')
})
test('currency table headers (Currency, Funds, Actions)', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(VIEWPORTS.desktop)
await page.goto(`${CLIENT}/wallet`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const pageContent = await page.textContent('body') || ''
const headers = ['Currency', 'Funds', 'Actions', 'Balance', 'Amount']
const found: string[] = []
for (const h of headers) {
if (pageContent.includes(h)) found.push(h)
}
console.log(` Table headers found: ${found.join(', ') || 'none (may need auth)'}`)
await snap(page, 'broker-deep-wallet-table')
})
test('wallet screenshots at all viewports', async ({ page, request }) => {
await skipIfClientDown(request)
await page.goto(`${CLIENT}/wallet`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
await snapAllViewports(page, 'wallet-shell')
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 9. Client Portal — Create Payment Shell
// ═══════════════════════════════════════════════════════════════════════════
test.describe('9 — Client Portal — Create Payment Shell', () => {
test('payment wizard steps visible', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(VIEWPORTS.desktop)
await page.goto(`${CLIENT}/create-payment`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const pageContent = await page.textContent('body') || ''
const steps = ['Currency', 'Beneficiary', 'Reason', 'Amount', 'Review', 'Pay']
const found: string[] = []
for (const step of steps) {
if (pageContent.includes(step)) found.push(step)
}
console.log(` Payment wizard steps found: ${found.join(', ') || 'none (may need auth)'}`)
await snap(page, 'broker-deep-payment-steps')
})
test('currency dropdown', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(VIEWPORTS.desktop)
await page.goto(`${CLIENT}/create-payment`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const dropdown = page.locator('select, [role="combobox"], [role="listbox"], [class*="select" i], [class*="dropdown" i]').first()
const visible = await dropdown.isVisible().catch(() => false)
console.log(` Currency dropdown visible: ${visible}`)
await snap(page, 'broker-deep-payment-currency')
})
test('beneficiary dropdown', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(VIEWPORTS.desktop)
await page.goto(`${CLIENT}/create-payment`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const pageContent = await page.textContent('body') || ''
const hasBeneficiary = pageContent.toLowerCase().includes('beneficiar')
console.log(` Beneficiary content present: ${hasBeneficiary}`)
await snap(page, 'broker-deep-payment-beneficiary')
})
test('continue button', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(VIEWPORTS.desktop)
await page.goto(`${CLIENT}/create-payment`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const continueBtn = page.locator('button:has-text("Continue"), button:has-text("Next"), button:has-text("Proceed"), button[type="submit"]').first()
const visible = await continueBtn.isVisible().catch(() => false)
console.log(` Continue button visible: ${visible}`)
await snap(page, 'broker-deep-payment-continue')
})
test('create-payment screenshots at all viewports', async ({ page, request }) => {
await skipIfClientDown(request)
await page.goto(`${CLIENT}/create-payment`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
await snapAllViewports(page, 'payment-shell')
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 10. Client Portal — Profile Page Shell
// ═══════════════════════════════════════════════════════════════════════════
test.describe('10 — Client Portal — Profile Page Shell', () => {
test('profile page renders', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(VIEWPORTS.desktop)
const res = await page.goto(`${CLIENT}/profile`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
const status = res?.status() || 0
console.log(` /profile: HTTP ${status}`)
expect([200, 302, 307, 500]).toContain(status)
await page.waitForTimeout(3000)
await snap(page, 'broker-deep-profile-render')
})
test('field labels visible', async ({ page, request }) => {
await skipIfClientDown(request)
await page.setViewportSize(VIEWPORTS.desktop)
await page.goto(`${CLIENT}/profile`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const pageContent = await page.textContent('body') || ''
const labels = ['Full Name', 'Account Type', 'Personal', 'Address', 'Employment', 'Bank Account', 'Profile']
const found: string[] = []
for (const label of labels) {
if (pageContent.includes(label)) found.push(label)
}
console.log(` Profile labels found: ${found.join(', ') || 'none (may need auth)'}`)
await snap(page, 'broker-deep-profile-labels')
})
test('profile screenshots at all viewports', async ({ page, request }) => {
await skipIfClientDown(request)
await page.goto(`${CLIENT}/profile`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
await snapAllViewports(page, 'profile-shell')
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 11. Admin Portal — Login (all viewports)
// ═══════════════════════════════════════════════════════════════════════════
test.describe('11 — Admin Portal — Login', () => {
for (const [vpName, vpSize] of Object.entries(VIEWPORTS)) {
test.describe(`@ ${vpName} (${vpSize.width}x${vpSize.height})`, () => {
test('login page loads with "Sign In" heading', async ({ page, request }) => {
await skipIfAdminDown(request)
await page.setViewportSize(vpSize)
await page.goto(ADMIN, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const heading = page.locator('text=Sign In').first()
await expect(heading).toBeVisible({ timeout: 10_000 })
await snap(page, `broker-deep-admin-login-heading-${vpName}`)
})
test('email and password fields with bordered inputs', async ({ page, request }) => {
await skipIfAdminDown(request)
await page.setViewportSize(vpSize)
await page.goto(ADMIN, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const emailInput = page.locator('input[type="email"], input[name="email"], input[placeholder*="email" i]').first()
const passwordInput = page.locator('input[type="password"], input[name="password"], input[placeholder*="password" i]').first()
await expect(emailInput).toBeVisible({ timeout: 5_000 })
await expect(passwordInput).toBeVisible({ timeout: 5_000 })
// Check bordered styling
const hasBorder = await emailInput.evaluate(el => {
const s = getComputedStyle(el)
return s.borderStyle !== 'none' && s.borderWidth !== '0px'
})
console.log(` [${vpName}] Email input has border: ${hasBorder}`)
})
test('"Log In" button (solid blue)', async ({ page, request }) => {
await skipIfAdminDown(request)
await page.setViewportSize(vpSize)
await page.goto(ADMIN, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const loginBtn = page.locator('button:has-text("Log In"), button:has-text("Login"), button:has-text("Sign In")').first()
await expect(loginBtn).toBeVisible({ timeout: 5_000 })
const bgColor = await loginBtn.evaluate(el => getComputedStyle(el).backgroundColor)
console.log(` [${vpName}] Log In button bg: ${bgColor}`)
})
test('back arrow visible', async ({ page, request }) => {
await skipIfAdminDown(request)
await page.setViewportSize(vpSize)
await page.goto(ADMIN, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
// Back arrow could be an SVG, icon, or link
const backArrow = page.locator('[class*="back" i], [class*="arrow" i], a[href="/"], svg').first()
const visible = await backArrow.isVisible().catch(() => false)
console.log(` [${vpName}] Back arrow visible: ${visible}`)
})
test('forgot password link', async ({ page, request }) => {
await skipIfAdminDown(request)
await page.setViewportSize(vpSize)
await page.goto(ADMIN, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const forgotLink = page.locator('text=Forgot Password, text=Forgot password').first()
const visible = await forgotLink.isVisible().catch(() => false)
console.log(` [${vpName}] Forgot Password visible: ${visible}`)
})
test('request registration link', async ({ page, request }) => {
await skipIfAdminDown(request)
await page.setViewportSize(vpSize)
await page.goto(ADMIN, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const regLink = page.locator('a').filter({ hasText: /[Rr]egist/ }).first()
const visible = await regLink.isVisible().catch(() => false)
console.log(` [${vpName}] Registration link visible: ${visible}`)
})
test('reCAPTCHA widget loads', async ({ page, request }) => {
await skipIfAdminDown(request)
await page.setViewportSize(vpSize)
await page.goto(ADMIN, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(5000)
const recaptchaFrame = page.locator('iframe[src*="recaptcha"]').first()
const recaptchaVisible = await recaptchaFrame.isVisible().catch(() => false)
console.log(` [${vpName}] reCAPTCHA iframe visible: ${recaptchaVisible}`)
if (recaptchaVisible) {
const src = await recaptchaFrame.getAttribute('src') || ''
const hasUndefinedKey = src.includes('k=undefined')
console.log(` [${vpName}] reCAPTCHA src contains k=undefined: ${hasUndefinedKey}`)
expect(hasUndefinedKey).toBe(false)
}
})
test('no horizontal overflow', async ({ page, request }) => {
await skipIfAdminDown(request)
await page.setViewportSize(vpSize)
await page.goto(ADMIN, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const noOverflow = await checkNoHorizontalOverflow(page)
if (!noOverflow) {
console.log(` WARN: horizontal overflow on admin login at ${vpName}`)
}
expect(noOverflow).toBe(true)
})
})
}
})
// ═══════════════════════════════════════════════════════════════════════════
// 12. Admin Portal — Core Screens (SPA routes return 200)
// ═══════════════════════════════════════════════════════════════════════════
test.describe('12 — Admin Portal — Core Screens', () => {
const ADMIN_ROUTES = [
{ path: '/', name: 'home' },
{ path: '/login', name: 'login' },
{ path: '/dashboard', name: 'dashboard' },
{ path: '/admin/clients', name: 'admin-clients' },
{ path: '/admin/admins', name: 'admin-admins' },
{ path: '/admin/users', name: 'admin-users' },
{ path: '/admin/archived-users', name: 'admin-archived-users' },
{ path: '/dashboard/beneficiaries', name: 'dash-beneficiaries' },
{ path: '/dashboard/balances', name: 'dash-balances' },
{ path: '/dashboard/payments/wizard', name: 'dash-payment-wizard' },
{ path: '/dashboard/conversion', name: 'dash-conversion' },
{ path: '/dashboard/users', name: 'dash-users' },
{ path: '/dashboard/documents', name: 'dash-documents' },
{ path: '/dashboard/search', name: 'dash-search' },
{ path: '/dashboard/profile', name: 'dash-profile' },
]
for (const route of ADMIN_ROUTES) {
test(`admin ${route.name} (${route.path}) returns 200`, async ({ page, request }) => {
await skipIfAdminDown(request)
await page.setViewportSize(VIEWPORTS.desktop)
const res = await page.goto(`${ADMIN}${route.path}`, {
timeout: 30_000,
waitUntil: 'domcontentloaded',
})
const status = res?.status() || 0
console.log(` ${route.path}: HTTP ${status}`)
// SPA serves index.html for all routes
expect(status).toBe(200)
await page.waitForTimeout(2000)
const bodyLen = (await page.textContent('body'))?.length || 0
console.log(` Body length: ${bodyLen}`)
await snap(page, `broker-deep-admin-${route.name}`)
})
}
})
// ═══════════════════════════════════════════════════════════════════════════
// 13. Admin Portal — Responsive (all viewports)
// ═══════════════════════════════════════════════════════════════════════════
test.describe('13 — Admin Portal — Responsive', () => {
for (const [vpName, vpSize] of Object.entries(VIEWPORTS)) {
test.describe(`@ ${vpName} (${vpSize.width}x${vpSize.height})`, () => {
test('no horizontal overflow on /login', async ({ page, request }) => {
await skipIfAdminDown(request)
await page.setViewportSize(vpSize)
await page.goto(`${ADMIN}/login`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const noOverflow = await checkNoHorizontalOverflow(page)
if (!noOverflow) {
console.log(` WARN: horizontal overflow on admin /login at ${vpName}`)
}
expect(noOverflow).toBe(true)
})
test('form centered on /login', async ({ page, request }) => {
await skipIfAdminDown(request)
await page.setViewportSize(vpSize)
await page.goto(`${ADMIN}/login`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const centered = await checkFormCentered(page)
console.log(` [${vpName}] Admin form centered: ${centered}`)
expect(centered).toBe(true)
})
test('content readable on /login', async ({ page, request }) => {
await skipIfAdminDown(request)
await page.setViewportSize(vpSize)
await page.goto(`${ADMIN}/login`, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const bodyLen = (await page.textContent('body'))?.length || 0
console.log(` [${vpName}] Body text length: ${bodyLen}`)
expect(bodyLen).toBeGreaterThan(20)
await snap(page, `broker-deep-admin-responsive-${vpName}`)
})
})
}
})
// ═══════════════════════════════════════════════════════════════════════════
// 14. UI Consistency Check
// ═══════════════════════════════════════════════════════════════════════════
test.describe('14 — UI Consistency Check', () => {
test('both apps have same page title "Lux Financial"', async ({ page, request }) => {
await skipIfClientDown(request)
await skipIfAdminDown(request)
await page.goto(CLIENT, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(2000)
const clientTitle = await page.title()
console.log(` Client title: "${clientTitle}"`)
await page.goto(ADMIN, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(2000)
const adminTitle = await page.title()
console.log(` Admin title: "${adminTitle}"`)
// Both should reference Lux Financial
const clientHasLux = clientTitle.toLowerCase().includes('lux')
const adminHasLux = adminTitle.toLowerCase().includes('lux')
console.log(` Client mentions Lux: ${clientHasLux}`)
console.log(` Admin mentions Lux: ${adminHasLux}`)
expect(clientTitle).toBeTruthy()
expect(adminTitle).toBeTruthy()
})
test('both apps have viewport meta tag', async ({ page, request }) => {
await skipIfClientDown(request)
await skipIfAdminDown(request)
await page.goto(CLIENT, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(2000)
const clientViewport = await page.evaluate(() => {
return document.querySelector('meta[name="viewport"]')?.getAttribute('content') || ''
})
console.log(` Client viewport: ${clientViewport}`)
expect(clientViewport).toContain('width=')
await page.goto(ADMIN, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(2000)
const adminViewport = await page.evaluate(() => {
return document.querySelector('meta[name="viewport"]')?.getAttribute('content') || ''
})
console.log(` Admin viewport: ${adminViewport}`)
expect(adminViewport).toContain('width=')
})
test('client app has @luxfi/ui tokens (CSS custom properties)', 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(` @luxfi/ui tokens: ${ui.hasTokens}`)
console.log(` Font family: ${ui.fontFamily.slice(0, 100)}`)
console.log(` BG color: ${ui.bgColor}`)
})
test('client app uses Tailwind (check for flex/grid/rounded classes)', 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(` Tailwind detected: ${ui.hasTailwind}`)
})
test('client app has dark mode support', 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(` Dark mode support: ${ui.hasDarkMode}`)
})
test('compare fonts between client and admin', async ({ page, request }) => {
await skipIfClientDown(request)
await skipIfAdminDown(request)
await page.goto(CLIENT, { timeout: 30_000, waitUntil: 'domcontentloaded' })
await page.waitForTimeout(3000)
const clientUI = await detectUIStack(page)
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(` Client font: "${clientFont}"`)
console.log(` Admin font: "${adminFont}"`)
console.log(` Fonts match: ${clientFont === adminFont}`)
console.log(` Client Zen: ${clientUI.hasZen}`)
console.log(` Admin Zen: ${adminUI.hasZen}`)
if (clientFont !== adminFont) {
console.log(` WARN: Font mismatch between client and admin apps`)
}
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 15. Security Headers Check
// ═══════════════════════════════════════════════════════════════════════════
test.describe('15 — Security Headers Check', () => {
test('client app does NOT expose x-powered-by', async ({ request }) => {
const res = await request.get(CLIENT, {
ignoreHTTPSErrors: true,
timeout: 15_000,
})
const headers = res.headers()
const poweredBy = headers['x-powered-by'] || 'not set'
console.log(` Client x-powered-by: ${poweredBy}`)
// Ideally should not expose framework info
if (poweredBy !== 'not set') {
console.log(` WARN: x-powered-by header is exposed: "${poweredBy}"`)
}
})
test('API returns proper content-type headers', async ({ request }) => {
const res = await request.get(API, {
ignoreHTTPSErrors: true,
timeout: 15_000,
headers: { Accept: 'application/json' },
})
const contentType = res.headers()['content-type'] || 'not set'
console.log(` API content-type: ${contentType}`)
expect(contentType).toBeTruthy()
expect(contentType).not.toBe('not set')
})
test('check for presence of security headers on client', async ({ request }) => {
const res = await request.get(CLIENT, {
ignoreHTTPSErrors: true,
timeout: 15_000,
})
const headers = res.headers()
const securityHeaders = [
'strict-transport-security',
'x-content-type-options',
'x-frame-options',
'content-security-policy',
'referrer-policy',
'permissions-policy',
]
for (const h of securityHeaders) {
const val = headers[h] || 'not set'
console.log(` ${h}: ${val.slice(0, 100)}`)
}
// At minimum, HSTS should be present on an HTTPS site
const hsts = headers['strict-transport-security']
if (!hsts) {
console.log(` WARN: strict-transport-security header missing`)
}
})
test('check for presence of security headers on admin', async ({ request }) => {
const res = await request.get(ADMIN, {
ignoreHTTPSErrors: true,
timeout: 15_000,
})
const headers = res.headers()
const securityHeaders = [
'strict-transport-security',
'x-content-type-options',
'x-frame-options',
'content-security-policy',
'referrer-policy',
'permissions-policy',
]
for (const h of securityHeaders) {
const val = headers[h] || 'not set'
console.log(` ${h}: ${val.slice(0, 100)}`)
}
})
test('check for presence of security headers on API', async ({ request }) => {
const res = await request.get(API, {
ignoreHTTPSErrors: true,
timeout: 15_000,
})
const headers = res.headers()
const securityHeaders = [
'strict-transport-security',
'x-content-type-options',
'x-frame-options',
'content-security-policy',
'referrer-policy',
]
for (const h of securityHeaders) {
const val = headers[h] || 'not set'
console.log(` ${h}: ${val.slice(0, 100)}`)
}
})
})