summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--frontend/scripts/ui-auth-shots.mjs189
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);