diff options
Diffstat (limited to 'frontend')
| -rw-r--r-- | frontend/scripts/ui-auth-shots.mjs | 189 |
1 files changed, 189 insertions, 0 deletions
diff --git a/frontend/scripts/ui-auth-shots.mjs b/frontend/scripts/ui-auth-shots.mjs new file mode 100644 index 0000000..c5c9ff3 --- /dev/null +++ b/frontend/scripts/ui-auth-shots.mjs @@ -0,0 +1,189 @@ +// 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 <outDir> +// 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); |
