Files
zeekay f627f20da1 e2e: Playwright smoke + multi-tenant specs for tenant bridge SPA
Env-parametric specs covering bridge.lux.network (and bridge.zoo.network
+ future tenants). Mirrors the wider Lux e2e env routing convention:

  E2E_ENV=local (default) → http://localhost:3001 (auto-boots vite preview)
  E2E_ENV=dev             → https://bridge.lux-dev.network
  E2E_ENV=test            → https://bridge.lux-test.network
  E2E_ENV=main            → https://bridge.lux.network
  BRIDGE_TENANT=zoo       → https://bridge.zoo.network (mainnet only)

smoke.spec.ts (6 cases, ~16s):
  1. Renders, title matches brand, SVG brand logo from @luxfi/logo
  2. From-chain selector has ≥ 2 options
  3. From-asset selector has ≥ 2 options after switching to Ethereum
     (default chain Lux only has the native LUX asset)
  4. Connect-wallet button visible + clickable (no real wallet connect)
  5. Typing an amount updates the "You receive" estimate via useSwap
  6. Production gate: served HTML + JS bundles free of `DEADBEEF`
     (the dev-only stub MPC display address)

multi-tenant.spec.ts (skips unless E2E_ENV=main):
  - For each tenant in the registry, probe DNS, navigate, assert
    document.title and the --brand-primary CSS variable set by
    applyBrandMetadata reflect the tenant's pinned brand block.

Playwright config auto-boots `vite preview` on :3001 from app/bridge/
when E2E_ENV=local, so `pnpm test:smoke` is one-step after `pnpm build`.
README documents the build prereq and the runner contract.

Out of scope (TODOs in code):
  - Real wallet connect (Playwright wallet mocking is a separate PR)
  - Real backend swap submit (prod backend not reliably reachable from CI)
  - Visual regression / screenshot diffs (separate PR)
2026-05-21 17:00:58 -07:00

197 lines
8.5 KiB
TypeScript

// Smoke spec for the tenant bridge SPA.
//
// Runs against `baseURL` from playwright.config.ts. Verifies:
//
// 1. Page renders (200 + #bridge-root present + React hydrated)
// 2. Title matches the configured brand (default: "Lux Bridge")
// 3. Brand logo SVG is in the DOM (img with data:image/svg+xml src,
// as wired by @luxfi/logo's getColorSVG → data URL)
// 4. From-chain selector has ≥ 2 options
// 5. From-asset selector has ≥ 2 options
// 6. "Connect Wallet" button visible + clickable (no real connect)
// 7. Typing an amount updates the "You receive" estimate
// 8. Production gate: bundled JS does NOT contain `DEADBEEF`
// (the dev-only stub MPC display address — see useWallet.ts)
//
// Deliberately does NOT cover:
// - Real wallet connect (needs Playwright wallet mocking — TODO)
// - Backend swap submit (prod backend not always reachable from CI — TODO)
// - Visual regression (separate PR)
import { test, expect, type Page } from '@playwright/test'
const TENANT = process.env.BRIDGE_TENANT ?? 'lux'
const EXPECTED_TITLE = TENANT === 'zoo' ? /zoo bridge/i : /lux bridge/i
/**
* Common page-load helper: navigate, collect any console / page errors,
* and assert the React tree mounted by waiting for the <header> element
* the SDK renders. Surfaces a precise error message when the SPA fails to
* mount (the most common failure mode is a missing runtime config such as
* the @hanzo/gui createGui() call producing the cryptic "Err0" throw).
*/
async function loadBridge(page: Page): Promise<{ errors: string[] }> {
const errors: string[] = []
page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`))
page.on('console', (m) => {
if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
})
const response = await page.goto('/')
expect(response?.status(), 'index.html responds 200').toBe(200)
// Wait up to 5s for the SDK to mount its <header>. If it never appears,
// surface the captured console errors instead of a generic timeout — the
// typical cause is a thrown error during render (Tamagui / @hanzo/gui
// configuration missing).
try {
await expect(page.locator('header')).toBeVisible({ timeout: 5_000 })
} catch (e) {
const summary =
errors.length > 0
? `\n\nSPA failed to mount. Captured errors:\n - ${errors.join('\n - ')}`
: '\n\nSPA failed to mount and no console errors were captured.'
throw new Error(`bridge SDK did not render <header>${summary}`)
}
return { errors }
}
test.describe(`[${TENANT}] bridge smoke`, () => {
test('renders, title matches brand, brand logo present', async ({ page }) => {
await loadBridge(page)
// Title is set by `applyBrandMetadata(brand)` at mount; for Lux the
// tenant config pins it to "Lux Bridge" (see app/bridge/index.html +
// src/bridge.config.ts).
await expect(page).toHaveTitle(EXPECTED_TITLE)
// The root element exists and is populated by mountBridge.
await expect(page.locator('#bridge-root')).toBeVisible()
// Brand logo: tenant config inlines an SVG data URL via @luxfi/logo's
// getColorSVG. Header.tsx renders it as `<img src={logoUrl} alt={name}>`.
const logo = page.locator('header img[alt$="Bridge" i]').first()
await expect(logo).toBeVisible()
const logoSrc = await logo.getAttribute('src')
expect(logoSrc, 'logo src is set').not.toBeNull()
expect(
logoSrc!.startsWith('data:image/svg+xml') || logoSrc!.endsWith('.svg'),
'logo is an SVG (data URL or .svg file)'
).toBe(true)
})
test('from-chain selector has multiple options', async ({ page }) => {
await loadBridge(page)
// ChainSelector.tsx labels the select as `From chain` (aria-label).
const fromChain = page.locator('select[aria-label="From chain"]')
await expect(fromChain).toBeVisible()
const optionCount = await fromChain.locator('option').count()
expect(optionCount, 'from-chain has ≥ 2 options').toBeGreaterThanOrEqual(2)
})
test('from-asset selector has multiple options once a multi-asset chain is selected', async ({
page,
}) => {
await loadBridge(page)
// The from-asset list is scoped to the currently-selected from-chain
// (see `assetsForChain` in pkg/bridge/src/app/lib/assets.ts). The default
// chain (Lux Network) only ships a single native asset, so we first
// switch to Ethereum, which has multiple assets in the static registry.
const fromChain = page.locator('select[aria-label="From chain"]')
await fromChain.selectOption({ label: 'Ethereum' })
const fromAsset = page.locator('select[aria-label="You send asset"]')
await expect(fromAsset).toBeVisible()
// Wait for the asset list to repopulate post chain switch.
await expect
.poll(async () => fromAsset.locator('option').count(), { timeout: 5_000 })
.toBeGreaterThanOrEqual(2)
})
test('connect wallet button is visible and clickable', async ({ page }) => {
await loadBridge(page)
// WalletConnect.tsx renders a button with the text "Connect Wallet" when
// wallet.address is null. The stub useWallet starts unconnected.
//
// Anchor inside <header> to disambiguate from the SwapForm submit
// button (text "Connect wallet to bridge") that fuzzy-matches the
// same name regex.
const connectBtn = page
.locator('header')
.getByRole('button', { name: /^connect wallet$/i })
await expect(connectBtn).toBeVisible()
await expect(connectBtn).toBeEnabled()
// Click is safe — the stub connect() either resolves to a dev address or
// throws ("Real MPC wallet wiring lands in Phase 3 R3."). We don't assert
// on the result; just that the button accepts a click without crashing
// the React tree. Real MPC mocking is a future spec.
await connectBtn.click().catch(() => {
// tolerate stub throw — see useWallet.ts
})
// Body is still rendered (React did not unmount).
await expect(page.locator('#bridge-root')).toBeVisible()
})
test('typing an amount updates the "You receive" estimate', async ({ page }) => {
await loadBridge(page)
const sendInput = page.locator('input[aria-label="You send amount"]')
const receiveInput = page.locator('input[aria-label="You receive amount"]')
await expect(sendInput).toBeVisible()
await expect(receiveInput).toBeVisible()
// Initial state: receive is empty (no quote yet).
const initialReceive = await receiveInput.inputValue()
expect(initialReceive, 'receive is empty before any input').toBe('')
// useSwap.ts derives a quote synchronously from amount. After typing,
// the quote populates and "You receive" reads via formatAmount(rate, 6).
await sendInput.fill('1')
// Wait for the receive box to update — useSwap is synchronous so this
// should happen on the next render tick; we allow generous slop.
await expect(receiveInput).not.toHaveValue('', { timeout: 5_000 })
const receivedValue = await receiveInput.inputValue()
expect(receivedValue.trim().length, 'receive has a value').toBeGreaterThan(0)
// It should also be numeric-ish (digits + optional decimal point + commas).
expect(receivedValue, 'receive looks like a number').toMatch(/^[\d.,]+$/)
})
test('production gate: page source does NOT contain DEADBEEF', async ({
page,
request,
}) => {
// The stub MPC wallet address (useWallet.ts) is `0xLUXBRIDGE...DEADBEEF`,
// guarded by `import.meta.env.DEV`. In production / preview / any
// tenant-deployed build, this literal must tree-shake to unreachable
// code and never appear in the served bundle.
//
// We check: (a) the page HTML doesn't contain it, and (b) the first JS
// bundle linked from index.html doesn't either. Source maps (.js.map)
// are explicitly excluded — those may legitimately retain identifiers
// when sourcemap: true is set in vite.config.ts.
await page.goto('/')
const html = await page.content()
expect(html, 'page HTML free of DEADBEEF').not.toContain('DEADBEEF')
// Pull every <script src> from the served HTML and probe them.
const scriptSrcs = await page.$$eval(
'script[src]',
(els) => (els as HTMLScriptElement[]).map((s) => s.src)
)
expect(scriptSrcs.length, 'page has at least one JS bundle').toBeGreaterThan(0)
for (const src of scriptSrcs) {
if (src.endsWith('.map')) continue
const r = await request.get(src)
expect(r.status(), `${src} responds 200`).toBe(200)
const body = await r.text()
expect(body, `${src} free of DEADBEEF`).not.toContain('DEADBEEF')
}
})
})