// 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 import { chromium } from 'playwright'; 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'; const outDir = path.resolve(process.argv[2] || '../artifacts/ui-round6'); 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 () { 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')) { 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(); }); }; send(path.join(dist, file)); }); await new Promise(function (r) { server.listen(0, '127.0.0.1', r); }); return 'http://127.0.0.1:' + server.address().port; })(); console.log('local probe origin:', site); const browser = await chromium.launch(); // ---- 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: 'domcontentloaded' }); await authPage.fill('#le', cred.email); await authPage.fill('#lp', cred.password); await authPage.click('form button:has-text("登录")'); await authPage.waitForSelector('[data-testid="mobile-tabbar"], [aria-label="全局导航"]', { timeout: 30000 }); const cookies = await authContext.cookies('https://fin.somhairle.bid'); 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(); 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 read = async function (name) { const dom = await page.evaluate(function () { const el = document.querySelector('[data-testid="mobile-tabbar"]'); const crumb = document.querySelector('nav[aria-label="面包屑"]'); return { 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[aria-label="面包屑"]')?.textContent.replace(/\s+/g, ' ').trim() || null, sparklineCells: document.querySelectorAll('[data-testid="sparkline-cell"]').length, metricCards: document.querySelectorAll('[data-testid="metric-card"]').length, cardRadius: (function () { const r = document.querySelector('[data-slot="card"]'); if (!r) return null; const target = String(getComputedStyle(document.documentElement).getPropertyValue('--radius')).trim(); return { computed: getComputedStyle(r).borderRadius, token: getComputedStyle(document.documentElement).getPropertyValue('--radius') || target }; })(), capitalReporting: !!document.querySelector('[data-testid="capital-reporting"]'), capitalTiles: document.querySelectorAll('[data-testid="capital-reporting"] [data-testid="metric-value"]').length, cardRadiusOk: (function () { const r = document.querySelector('[data-slot="card"]'); if (!r) return null; const root = getComputedStyle(document.documentElement); const tok = parseFloat(root.getPropertyValue('--radius')); const rootFs = parseFloat(root.fontSize); const expected = tok * rootFs; const n = parseFloat(getComputedStyle(r).borderRadius); return n >= expected - 0.5 && tok > 0; })(), projects: (window.__probeProjects !== undefined) ? window.__probeProjects : null, runs: null, hasEquity: null, usageRequests: null }; }); const key = name + '@' + w; 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 + ' radiusOk=' + dom.cardRadiusOk + ' capital=' + dom.capitalReporting + (dom.breadcrumbText ? ' crumbs="' + dom.breadcrumbText + '"' : '') ); if (dom.overflow !== 0 || pageErrors.length !== 0) bad++; }; // 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"], [aria-label="全局导航"]', { 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 }; }); 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 read('loggedIn-projects'); for (const tab of ['runs', 'datasets']) { await page.goto(site + '/#/' + tab, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('[data-testid="mobile-tabbar"], [aria-label="全局导航"]', { state: 'attached', timeout: 20000 }); await read(tab); } // project deep pages: click-through the projects list; requires >=1 project await page.goto(site + '/#/projects', { waitUntil: 'domcontentloaded' }); await page.waitForSelector('a[data-testid="project-link"], a.p-name', { timeout: 20000 }).catch(function () {}); const first = page.locator('a[data-testid="project-link"], a.p-name').first(); if ((await first.count()) > 0) { await first.click(); await page.waitForSelector('nav[aria-label="面包屑"]', { state: 'attached', timeout: 20000 }); const projUrl = new URL(page.url()); const projHash = projUrl.hash; for (const tab of ['data', 'strategy', 'backtest', 'results']) { 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"], [aria-label="全局导航"]', { state: 'attached', timeout: 20000 }); await page.waitForSelector('nav[aria-label="面包屑"], .projctx, main', { state: 'attached', timeout: 15000 }); // round6: each journey step must finish its data load (skeleton gone) await page.waitForFunction(function () { return !document.querySelector('[data-testid="busy-skeleton"]'); }, { timeout: 20000 }).catch(function () {}); if (tab === 'results') { await page.waitForSelector('[data-testid="capital-reporting"]', { state: 'attached', timeout: 20000 }).catch(function () {}); if (!(await page.locator('[data-testid="capital-reporting"]').count())) await page.getByText('还没有可展示的回测').first().waitFor({ timeout: 10000 }).catch(function () {}); } await read('proj-' + tab); } } else { console.log('SKIP proj-*@' + w + ' (no project rows; precondition unmet on prod data)'); } await context.close(); } await browser.close(); writeFileSync(path.join(outDir, 'auth-shots.json'), JSON.stringify(results, null, 2)); console.log('shots:', Object.keys(results).filter(function (k) { return !k.endsWith('.pre'); }).length, 'bad=' + bad); process.exit(bad === 0 ? 0 : 1);