// Authenticated visual verification: login to the deployed site with the // admin credentials, then capture projects/runs/datasets + a project's // data/strategy/results pages at 375/768/1440 into an artifacts dir. // Usage: node frontend/scripts/ui-auth-shots.mjs // Credentials come from strategy-lab-production admin.private.json (never logged). import { chromium } from 'playwright'; import { mkdirSync, writeFileSync, readFileSync, createReadStream } from 'node:fs'; import path from 'node:path'; import http from 'node:http'; // Optional: serve a local dist build, proxying /api to the deployed backend // (fin.somhairle.bid) so real data shows with the code under test. const distEnv = process.env.AUTH_SHOTS_DIST || ''; const distOrigin = Boolean(distEnv); // Credentials come from strategy-lab-production admin.private.json (never logged). const cred = JSON.parse(readFileSync('/home/somhairle/.local/share/strategy-lab-production/admin.private.json', 'utf8')); const outDir = path.resolve(process.argv[2] || '../artifacts/ui-shadcn-round2'); const widthList = [375, 768, 1440]; const browser = await chromium.launch(); const results = {}; const site = await (async function () { if (!distEnv) return 'https://fin.somhairle.bid'; const dist = path.resolve(distEnv); const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2', '.json': 'application/json' }; const server = http.createServer(async function (req, res) { const url = req.url || '/'; if (url.startsWith('/api')) { try { const chunks = []; for await (const c of req) chunks.push(c); const body = Buffer.concat(chunks); const headers = { ...req.headers }; delete headers.host; delete headers['accept-encoding']; headers.origin = 'https://fin.somhairle.bid'; headers.referer = 'https://fin.somhairle.bid/'; const upstream = await fetch('https://fin.somhairle.bid' + url.split('?')[0] + (url.includes('?') ? '?' + url.split('?')[1] : ''), { method: req.method, headers: headers, body: ['GET', 'HEAD'].includes(req.method) ? undefined : body }); const buf = Buffer.from(await upstream.arrayBuffer()); res.writeHead(upstream.status, Object.fromEntries([...upstream.headers.entries()].filter(function (h) { return !["content-encoding", "transfer-encoding"].includes(h[0]); }))); if (url.includes("auth")) console.log("[proxy]", req.method, url, upstream.status); res.end(buf); } catch (e) { res.writeHead(502, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { code: 'proxy_fail', message: String(e) } })); } return; } let file = url.split('?')[0].split('#')[0]; if (file === '/' || file === '') file = '/index.html'; const send = function (fp, fallback) { const stream = createReadStream(fp); stream.on('open', function () { res.writeHead(200, { 'content-type': MIME[path.extname(fp)] || 'application/octet-stream' }); stream.pipe(res); }); stream.on('error', function () { if (!fallback) send(path.join(dist, 'index.html'), true); else res.writeHead(404).end(); }); }; send(path.join(dist, file)); }); await new Promise(function (r) { server.listen(0, '127.0.0.1', r); }); const port = server.address().port; return 'http://127.0.0.1:' + port; })(); mkdirSync(outDir, { recursive: true }); // Authenticate once against the deployed site and reuse the session cookie // (HttpOnly+Secure), since the local dist server is plain http and the cookie // would be dropped there. const authContext = await browser.newContext({ ignoreHTTPSErrors: true }); const authPage = await authContext.newPage(); await authPage.goto('https://fin.somhairle.bid/#/login', { waitUntil: 'networkidle' }); await authPage.fill('#le', cred.email); await authPage.fill('#lp', cred.password); await authPage.click('button[type="submit"], form button'); await authPage.waitForURL(/#\/projects/, { timeout: 30000 }).catch(async function () { await authPage.waitForTimeout(2000); }); const cookies = await authContext.cookies('https://fin.somhairle.bid'); const hasSession = cookies.some(function (c) { return c.name === 'sl_session'; }); console.log('auth:', hasSession ? 'ok (sl_session obtained on live site)' : 'FAILED to obtain session'); await authContext.close(); for (const w of widthList) { const context = await browser.newContext({ viewport: { width: w, height: Math.round(w * 2) }, ignoreHTTPSErrors: true }); const page = await context.newPage(); const pageErrors = []; page.on('pageerror', function (e) { pageErrors.push(String(e)); }); const shot = async function (name) { const fp = path.join(outDir, name + '-' + w + '.png'); await page.screenshot({ path: fp, fullPage: true }); await page.waitForTimeout(200); const ui = await page.evaluate(function () { const tabbar = document.querySelector('[data-testid="mobile-tabbar"]'); return { tabbarVisible: !!tabbar && getComputedStyle(tabbar).display !== 'none', sparklineCells: document.querySelectorAll('[data-testid="sparkline-cell"]').length, metricCards: document.querySelectorAll('.metric-card, [data-testid="metric-card"]').length, scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth }; }); const key = name + '@' + w; results[key] = { url: decodeURIComponent(page.url().split('#')[1] || page.url()), overflow: Math.max(0, ui.scrollWidth - ui.clientWidth), pageErrors: pageErrors.slice(), tabbarVisible: ui.tabbarVisible, sparklineCells: ui.sparklineCells, metricCards: ui.metricCards, shot: path.relative(process.cwd(), fp) }; }; await context.addCookies( cookies.map(function (c) { return { name: c.name, value: c.value, domain: distOrigin ? new URL(site).hostname : c.domain, path: '/', expires: c.expires, httpOnly: true, secure: c.secure, sameSite: 'Lax' }; }) ); await page.goto(site + '/#/projects', { waitUntil: 'networkidle' }); await page.waitForTimeout(800); const authed = await page.evaluate(async function () { const r = await fetch('/api/auth/me', { credentials: 'same-origin' }); return r.status; }); if (authed !== 200) { console.log('WARN: session not accepted on ' + site + ' (me=' + authed + '), falling back to live login'); await page.goto('https://fin.somhairle.bid/#/login', { waitUntil: 'networkidle' }); await page.fill('#le', cred.email); await page.fill('#lp', cred.password); await page.click('button[type="submit"], form button'); await page.waitForURL(/#\/projects/, { timeout: 30000 }).catch(async function () { await page.waitForTimeout(2000); }); } await page.goto(site + '/#/projects', { waitUntil: 'networkidle' }); await page.waitForTimeout(600); await shot('loggedIn-projects'); for (const tab of ['runs', 'datasets']) { await page.goto(site + '/#/' + tab, { waitUntil: 'networkidle' }); await page.waitForTimeout(500); await shot(tab); } // project deep pages via first project link on the projects list await page.goto(site + '/#/projects', { waitUntil: 'networkidle' }); await page.waitForTimeout(500); const first = page.locator('a.p-name').first(); const hasProject = (await first.count()) > 0; if (hasProject) { await first.click(); await page.waitForTimeout(700); const projBase = page.url().split('?')[0]; for (const tab of ['data', 'strategy', 'results']) { await page.goto(projBase.replace(/\/(data|strategy|results|backtest|runs)$/, '/' + tab), { waitUntil: 'networkidle' }); await page.waitForTimeout(700); await shot('proj-' + tab); } } await context.close(); } await browser.close(); writeFileSync(path.join(outDir, 'auth-shots.json'), JSON.stringify(results, null, 2)); let bad = 0; for (const k of Object.keys(results)) { const r = results[k]; const ok = r.overflow === 0 && r.pageErrors.length === 0; console.log((ok ? 'OK ' : 'FAIL ') + k + ' overflow=' + r.overflow + ' pageErrors=' + r.pageErrors.length + ' tabbar=' + r.tabbarVisible + ' sparks=' + r.sparklineCells + ' metricCards=' + r.metricCards); if (!ok) bad++; } console.log('shots:', Object.keys(results).length); process.exit(bad === 0 ? 0 : 1);