Add console.lux.cloud (Lux Cloud Console — the web3/infra console app) as a lux surface with its real route list (networks, nodes, gateway, api-keys, tokens, indexer, oracles, wallet, settlement, usage/billing, kms/secrets, …) and the valid lux.id password-grant login ([email protected]). Harness login() now waits for the sign-in form and matches Tamagui inputs (autocomplete="email" / placeholder="Email"), and treats /signin as a login gate — without this the harness could not fill or detect this console's form. Capture currently yields the /signin gate only: login is blocked IAM-side — the lux-cloud app in lux.id is provisioned for host cloud.lux.network, so console.lux.cloud/auth/callback is not an allowed redirect URI. Durable fix (separate, out of this change): add console.lux.cloud to the `cloud` app hosts in infra/k8s/iam/provision.yaml and reconcile IAM. Co-authored-by: Hanzo Dev <[email protected]>
123 lines
7.2 KiB
JavaScript
123 lines
7.2 KiB
JavaScript
#!/usr/bin/env node
|
|
// Re-runnable screenshot harness for every white-label surface.
|
|
// Usage: BRAND=lux node capture.mjs (default brand=lux)
|
|
// BRAND=acm|local node capture.mjs
|
|
// ONLY=admin-bank node capture.mjs (filter to one surface)
|
|
// Public surfaces: auto-discovers pages (same-origin links) + goto each.
|
|
// Auth'd surfaces (login set): logs in ONCE, then navigates CLIENT-SIDE
|
|
// (clicks in-app nav — never reloads) because these SPAs hold auth in React
|
|
// state, not localStorage/cookies. Shoots desktop + mobile full-page into
|
|
// ~/Desktop/<brand>. Down surfaces are skipped, never fatal.
|
|
import { chromium } from 'playwright';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
const BRAND = process.env.BRAND || 'lux';
|
|
const ONLY = process.env.ONLY || '';
|
|
const here = path.dirname(new URL(import.meta.url).pathname);
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(here, 'manifest.json'), 'utf8'));
|
|
const brand = manifest.brands[BRAND];
|
|
if (!brand) { console.error(`unknown brand "${BRAND}"`); process.exit(1); }
|
|
const outRoot = brand.outDir.replace(/^~/, os.homedir());
|
|
const MAXP = manifest.maxPagesPerSurface || 24;
|
|
const VPS = manifest.viewports;
|
|
const slug = (u) => { try { const p = new URL(u).pathname.replace(/\/+$/, ''); return (p || 'home').replace(/[^a-z0-9]+/gi, '_').replace(/^_|_$/g, '') || 'home'; } catch { return 'home'; } };
|
|
|
|
const QUERY = brand.appendQuery || '';
|
|
async function login(page, base, cfg) {
|
|
await page.goto(base + (cfg.at || '/login') + QUERY, { waitUntil: 'networkidle', timeout: 30000 }).catch(() => {});
|
|
// Some SPAs (@hanzo/gui / Tamagui consoles) resolve session before painting the
|
|
// form and render an email input as type="text" autocomplete="email" — so wait for
|
|
// the field and match those variants too, not just type="email"/name="email".
|
|
const emSel = 'input[type="email"], input[name="email"], input[autocomplete="username"], input[autocomplete="email"], input[placeholder="Email" i]';
|
|
await page.waitForSelector(emSel, { timeout: 12000 }).catch(() => {});
|
|
await page.waitForTimeout(800);
|
|
const em = page.locator(emSel).first();
|
|
const pw = page.locator('input[type="password"]').first();
|
|
if (await em.count()) await em.fill(cfg.email);
|
|
if (await pw.count()) await pw.fill(cfg.password);
|
|
const btn = page.locator('button[type="submit"], button:has-text("Sign in"), button:has-text("Log in")').first();
|
|
if (await btn.count()) await btn.click().catch(() => {}); else await pw.press('Enter').catch(() => {});
|
|
await page.waitForTimeout(2500);
|
|
const stillLogin = /\/(login|signin)/.test(page.url()) && await page.locator('input[type="password"]').count() > 0;
|
|
return !stillLogin;
|
|
}
|
|
|
|
const REBRAND = brand.rebrand || null;
|
|
async function applyRebrand(page) {
|
|
if (!REBRAND) return;
|
|
await page.evaluate((rb) => {
|
|
let s = document.getElementById('__rebrand'); if (!s) { s = document.createElement('style'); s.id = '__rebrand'; document.head.appendChild(s); }
|
|
s.textContent = `:root{--accent:${rb.accent}!important;--primary:${rb.accent}!important;--ring:${rb.accent}!important;--brand:${rb.accent}!important;}`;
|
|
const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
const nodes = []; while (walk.nextNode()) nodes.push(walk.currentNode);
|
|
for (const n of nodes) { let t = n.nodeValue; for (const [a, b] of rb.replace) t = t.split(a).join(b); if (t !== n.nodeValue) n.nodeValue = t; }
|
|
}, REBRAND).catch(() => {});
|
|
}
|
|
async function shootHere(page, dir, name, vp) {
|
|
try {
|
|
await page.waitForTimeout(1000);
|
|
await applyRebrand(page);
|
|
await page.waitForTimeout(200);
|
|
await page.screenshot({ path: path.join(dir, `${name}-${vp}.png`), fullPage: true });
|
|
console.log(` ✓ ${vp.padEnd(7)} ${name}`);
|
|
} catch (e) { console.log(` ✗ ${vp} ${name} (${e.message.split('\n')[0]})`); }
|
|
}
|
|
|
|
async function reachable(url) {
|
|
try { const r = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(8000) }); return !(r.status >= 500 || r.status === 0); } catch { return false; }
|
|
}
|
|
|
|
const browser = await chromium.launch({ headless: true });
|
|
let total = 0;
|
|
for (const s of brand.surfaces) {
|
|
if (ONLY && s.name !== ONLY) continue;
|
|
console.log(`\n▸ ${s.name} ${s.url}`);
|
|
const dir = path.join(outRoot, s.name);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
if (!(await reachable(s.url))) { console.log(' (skipped — not reachable)'); continue; }
|
|
|
|
const vpEntries = Object.entries(VPS).filter(([vp]) => !brand.viewportsOnly || brand.viewportsOnly.includes(vp));
|
|
for (const [vp, cfg] of vpEntries) {
|
|
const ctx = await browser.newContext({ viewport: { width: cfg.width, height: cfg.height }, isMobile: !!cfg.isMobile, deviceScaleFactor: cfg.deviceScaleFactor || 1, ignoreHTTPSErrors: true });
|
|
const page = await ctx.newPage();
|
|
if (s.login) {
|
|
const ok = await login(page, s.url, s.login);
|
|
if (!ok) { console.log(` ! login did not clear /login — capturing gate only`); await shootHere(page, dir, 'login', vp); total++; await ctx.close(); continue; }
|
|
// client-side nav: shoot home, then click each in-app nav link (no reload → React auth persists)
|
|
await shootHere(page, dir, 'home', vp); total++;
|
|
const seen = new Set(['/']);
|
|
const hrefs = await page.$$eval('a[href^="/"]', (as) => [...new Set(as.map((a) => new URL(a.href).pathname))]).catch(() => []);
|
|
for (const r of (s.authedRoutes || [])) { try { hrefs.push(new URL(r, s.url).pathname); } catch {} }
|
|
for (const href of [...new Set(hrefs)].slice(0, MAXP)) {
|
|
if (seen.has(href)) continue; seen.add(href);
|
|
const link = page.locator(`a[href="${href}"], a[href="${href}/"]`).first();
|
|
let navved = false;
|
|
if (await link.count()) { try { await link.click({ timeout: 3500 }); navved = true; } catch {} }
|
|
if (!navved) { // hidden (mobile hamburger) or click failed → client-side route push
|
|
await page.evaluate((h) => { history.pushState({}, '', h); window.dispatchEvent(new PopStateEvent('popstate')); }, href).catch(() => {});
|
|
}
|
|
await page.waitForTimeout(500);
|
|
await shootHere(page, dir, slug(s.url + href), vp); total++;
|
|
}
|
|
} else {
|
|
// public surface: discover links + goto each
|
|
await page.goto(s.url, { waitUntil: 'networkidle', timeout: 30000 }).catch(() => {});
|
|
const origin = new URL(s.url).origin;
|
|
const urls = new Set([s.url + '/']);
|
|
const hrefs = await page.$$eval('a[href]', (as) => as.map((a) => a.href)).catch(() => []);
|
|
for (const h of hrefs) { try { const u = new URL(h); if (u.origin === origin && !u.hash && !/\.(pdf|zip|png|jpg|svg|mp4)$/i.test(u.pathname)) urls.add(u.origin + u.pathname); } catch {} }
|
|
for (const r of (s.authedRoutes || [])) urls.add(new URL(r, s.url).href);
|
|
for (const url of [...urls].slice(0, MAXP)) {
|
|
const pg = await ctx.newPage();
|
|
try { await pg.goto(url, { waitUntil: 'networkidle', timeout: 30000 }).catch(() => pg.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 })); await shootHere(pg, dir, slug(url), vp); total++; } catch {}
|
|
finally { await pg.close(); }
|
|
}
|
|
}
|
|
await ctx.close();
|
|
}
|
|
}
|
|
await browser.close();
|
|
console.log(`\n✅ ${BRAND}: ${total} screenshots → ${outRoot}`);
|