summaryrefslogtreecommitdiff
path: root/frontend/scripts/ui-auth-shots.mjs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-18 10:05:50 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-18 10:05:50 +0800
commit6f07c14efe8bc73f20ce9ef7313b93a3b0cb6067 (patch)
treeec57664bfbedf80a822e43a9e13466da605dad38 /frontend/scripts/ui-auth-shots.mjs
parentc71930f79b102f5a3cade7877dba425462ed758a (diff)
downloadstrategy-lab-6f07c14efe8bc73f20ce9ef7313b93a3b0cb6067.tar.gz
fix(frontend): ui-auth-shots 构建先行 + 条件等待 + DOM 事实断言
[变更性质] 验证脚本返工(Leader 验收问题 1/2)。 [问题根因] 旧版默认打生产站点:生产仍为旧构建(无 Tab Bar),导致 Leader 复跑 18 组合全 false;另固定 waitForTimeout 可能早于挂载。 [修复方案] 默认先 npm run build 再启动本地代理(输出所服务资产哈希);waitForSelector 条件等待挂载;断言输出 tabbarAttached/tabbarVisible 与面包屑文本两套 DOM 事实;每轮先验 /api/auth/me=200;projects/runs/usage 前置条件随 JSON 记录,projects=0 时显式 SKIP 而非恒假断言。 [影响范围] frontend/scripts/ui-auth-shots.mjs;实测 375 tabbarVisible=true、metricCards=4、sparks=2,18/18 bad=0。
Diffstat (limited to 'frontend/scripts/ui-auth-shots.mjs')
-rw-r--r--frontend/scripts/ui-auth-shots.mjs302
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);