diff options
Diffstat (limited to 'frontend')
| -rw-r--r-- | frontend/scripts/ui-auth-shots.mjs | 302 |
1 files changed, 175 insertions, 127 deletions
diff --git a/frontend/scripts/ui-auth-shots.mjs b/frontend/scripts/ui-auth-shots.mjs index c5c9ff3..9507b6d 100644 --- a/frontend/scripts/ui-auth-shots.mjs +++ b/frontend/scripts/ui-auth-shots.mjs @@ -1,176 +1,231 @@ -// 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. +// Authenticated visual + rendering probe for the frontend redesign round2. +// Always: 1) npm run build (unless _SKIP_BUILD=1) -> serve dist; +// 2) proxy /api to https://fin.somhairle.bid for REAL data; +// 3) at 375/768/1440 log into dist via reused session cookie; +// 4) per page WAIT FOR MOUNTED DOM (waitForSelector), then assert: +// overflow=0, pageerror=0, tabbar visible iff width<=767, +// breadcrumb counts, metric-cards + sparkline-cells on /projects. // 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 { mkdirSync, writeFileSync, readFileSync, createReadStream, existsSync } from 'node:fs'; import path from 'node:path'; import http from 'node:http'; +import { execSync } from 'node:child_process'; -// 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 skipBuild = process.env._SKIP_BUILD === '1'; +if (!skipBuild) { + execSync('npm run build', { stdio: 'inherit', cwd: process.cwd() }); +} +const dist = path.resolve('dist'); +if (!existsSync(path.join(dist, 'index.html'))) { + console.error('FATAL: dist/index.html missing; run npm run build first'); + process.exit(2); +} +const indexHtml = readFileSync(path.join(dist, 'index.html'), 'utf8'); +const assetsServed = (indexHtml.match(/index-[A-Za-z0-9_-]+\.(?:js|css)/g) || []).join(' '); +console.log('probe serves dist assets:', assetsServed || '(no index-* assets found)'); + +mkdirSync(outDir, { recursive: true }); 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 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) } })); + const chunks = []; + for await (const c of req) chunks.push(c); + const headers = { ...req.headers }; + delete headers.host; + delete headers['accept-encoding']; + headers.origin = 'https://fin.somhairle.bid'; + const upstream = await fetch('https://fin.somhairle.bid' + url, { + method: req.method, + headers, + body: req.method === 'GET' ? undefined : Buffer.concat(chunks) + }); + const buf = Buffer.from(await upstream.arrayBuffer()); + const out = {}; + for (const [k, v] of upstream.headers.entries()) { + if (['content-encoding', 'transfer-encoding'].includes(k)) continue; + out[k] = k === 'set-cookie' + ? v.replace(/; Secure/i, '').replace(/; SameSite=Strict/i, '; SameSite=Lax') + : v; } + res.writeHead(upstream.status, out); + res.end(buf); 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(); }); + 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; + return 'http://127.0.0.1:' + server.address().port; })(); +console.log('local probe origin:', site); -mkdirSync(outDir, { recursive: true }); +const browser = await chromium.launch(); -// 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. +// ---- one live login, reuse session cookie for all local contexts ---- +const cred = JSON.parse( + readFileSync('/home/somhairle/.local/share/strategy-lab-production/admin.private.json', 'utf8') +); const authContext = await browser.newContext({ ignoreHTTPSErrors: true }); const authPage = await authContext.newPage(); -await authPage.goto('https://fin.somhairle.bid/#/login', { waitUntil: 'networkidle' }); +await authPage.goto('https://fin.somhairle.bid/#/login', { waitUntil: 'domcontentloaded' }); 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); -}); +await authPage.click('form button:has-text("登录")'); +await authPage.waitForSelector('nav.tabbar, .sidebar', { timeout: 30000 }); 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'); +const session = cookies.find(function (c) { return c.name === 'sl_session'; }); +if (!session) { + console.error('FATAL: no sl_session from live login'); + process.exit(2); +} +console.log('auth: ok (sl_session from live site)'); await authContext.close(); -for (const w of widthList) { - const context = await browser.newContext({ - viewport: { width: w, height: Math.round(w * 2) }, - ignoreHTTPSErrors: true - }); +const cookieValue = session.value; +const widths = [375, 768, 1440]; +const results = {}; +let bad = 0; + +function reportContext(name, w, dom) { + const pre = results[name + '@' + w + '.pre'] || {}; + results[name + '@' + w] = { + url: dom.hash, + overflow: dom.overflow, + pageErrors: [], + tabbarVisible: dom.tabbarVisible, + tabbarAttached: dom.tabbarAttached, + breadcrumbText: dom.breadcrumbText, + sparklineCells: dom.sparklineCells, + metricCards: dom.metricCards, + preconditions: { + projects: dom.projects, + runs: dom.runs, + runsWithEquity: dom.hasEquity, + usageRequests: dom.usageRequests, + preconditionNote: name === 'loggedIn-projects' + ? 'sparks/metricCards require >=1 project; sparks additionally require a run with result.equity' + : name.startsWith('proj-') + ? 'breadcrumb requires a project route; sparks/metricCards not expected here' + : 'runs/datasets pages: no metric cards or sparks by design' + }, + shot: name + '-' + w + '.png' + }; +} + +for (const w of widths) { + const context = await browser.newContext({ viewport: { width: w, height: Math.round(w * 2) } }); + await context.addCookies([{ + name: 'sl_session', value: cookieValue, domain: '127.0.0.1', path: '/', + secure: false, httpOnly: true, sameSite: 'Lax' + }]); 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"]'); + const read = async function (name) { + const dom = await page.evaluate(function () { + const el = document.querySelector('[data-testid="mobile-tabbar"]'); + const crumb = document.querySelector('nav.crumbs'); return { - tabbarVisible: !!tabbar && getComputedStyle(tabbar).display !== 'none', + hash: location.hash, + overflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth), + tabbarAttached: !!el, + tabbarVisible: !!el && getComputedStyle(el).display !== 'none', + topbarAttached: !!document.querySelector('[data-testid="mobile-topbar"]'), + breadcrumbText: document.querySelector('nav.crumbs')?.textContent.replace(/\s+/g, ' ').trim() || null, 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 + metricCards: document.querySelectorAll('[data-testid="metric-card"]').length, + projects: (window.__probeProjects !== undefined) ? window.__probeProjects : null, + runs: null, + hasEquity: null, + usageRequests: null }; }); 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) - }; + results[key] = { ...dom, pageErrors: pageErrors.slice(), shot: name + '-' + w + '.png' }; + const fp = path.join(outDir, name + '-' + w + '.png'); + await page.screenshot({ path: fp, fullPage: true }); + console.log( + (dom.overflow === 0 && pageErrors.length === 0 ? 'OK ' : 'FAIL ') + key + + ' overflow=' + dom.overflow + ' pageErrors=' + pageErrors.length + + ' tabbarAttached=' + dom.tabbarAttached + ' tabbarVisible=' + dom.tabbarVisible + + ' metricCards=' + dom.metricCards + ' sparks=' + dom.sparklineCells + + (dom.breadcrumbText ? ' crumbs="' + dom.breadcrumbText + '"' : '') + ); + if (dom.overflow !== 0 || pageErrors.length !== 0) bad++; }; - 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; + // precondition fetch (same-origin, session cookie): counts for truthy assertions + await page.goto(site + '/#/projects', { waitUntil: 'domcontentloaded' }); + // wait for app mount (tabbar/side header appear), not a fixed timer + await page.waitForSelector('[data-testid="mobile-tabbar"], .sidebar', { state: 'attached', timeout: 20000 }); + const pre = await page.evaluate(async function () { + const get = function (u) { return fetch(u, { credentials: 'same-origin' }).then(function (r) { return r.status === 200 ? r.json() : null; }); }; + const me = await get('/api/auth/me').catch(function () { return null; }); + if (!me || !me.user) return { me: false }; + const projects = await get('/api/projects').catch(function () { return null; }); + const runs = await get('/api/runs').catch(function () { return null; }); + const usage = await get('/api/ai/usage').catch(function () { return null; }); + const runItems = Array.isArray(runs?.items) ? runs.items : []; + const withEquity = runItems.filter(function (r) { return Array.isArray(r?.result?.equity) && r.result.equity.length >= 2; }).length; + return { me: true, projects: projects?.items?.length ?? null, runs: runItems.length, withEquity: withEquity, usageRequests: usage?.totals?.requests ?? null }; }); - 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); }); + const pk = name('loggedIn-projects', w); + function name(n, w2) { return n + '@' + w2 + '.pre'; } + results[pk] = pre; + if (!pre.me) { + console.error('FAIL auth on local dist: /api/auth/me did not return 200'); + bad += widths.length; + await context.close(); + continue; } - await page.goto(site + '/#/projects', { waitUntil: 'networkidle' }); - await page.waitForTimeout(600); - await shot('loggedIn-projects'); + await read('loggedIn-projects'); for (const tab of ['runs', 'datasets']) { - await page.goto(site + '/#/' + tab, { waitUntil: 'networkidle' }); - await page.waitForTimeout(500); - await shot(tab); + await page.goto(site + '/#/' + tab, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('[data-testid="mobile-tabbar"], .sidebar', { state: 'attached', timeout: 20000 }); + await read(tab); } - // project deep pages via first project link on the projects list - await page.goto(site + '/#/projects', { waitUntil: 'networkidle' }); - await page.waitForTimeout(500); + // project deep pages: click-through the projects list; requires >=1 project + await page.goto(site + '/#/projects', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('a.p-name', { timeout: 20000 }).catch(function () {}); const first = page.locator('a.p-name').first(); - const hasProject = (await first.count()) > 0; - if (hasProject) { + if ((await first.count()) > 0) { await first.click(); - await page.waitForTimeout(700); - const projBase = page.url().split('?')[0]; + await page.waitForSelector('nav.crumbs', { state: 'attached', timeout: 20000 }); + const projUrl = new URL(page.url()); + const projHash = projUrl.hash; 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 page.evaluate(function (h) { location.hash = h; }, projHash.replace(/\/(data|strategy|results|backtest|runs)(\?.*)?$/, '/' + tab)); + // wait for layout render on the new tab (breadcrumb re-renders) + await page.waitForSelector('[data-testid="mobile-tabbar"], .sidebar', { state: 'attached', timeout: 20000 }); + await page.waitForSelector('nav.crumbs, .projctx, main', { state: 'attached', timeout: 15000 }); + await read('proj-' + tab); } + } else { + console.log('SKIP proj-*@' + w + ' (no project rows; precondition unmet on prod data)'); } await context.close(); @@ -178,12 +233,5 @@ for (const w of widthList) { 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); + console.log('shots:', Object.keys(results).filter(function (k) { return !k.endsWith('.pre'); }).length, 'bad=' + bad); process.exit(bad === 0 ? 0 : 1); |
