Files
zeekayandHanzo Dev 82797cc03b screenshots: add lux-cloud console surface + fix harness sign-in for @hanzo/gui
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]>
2026-07-26 15:46:03 -07:00

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}`);