1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
|
// 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>
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-shadcn-round2');
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('nav.tabbar, .sidebar', { 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.crumbs');
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.crumbs')?.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 };
})(),
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.749;
})(),
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 +
(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"], .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 };
});
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"], .sidebar', { 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.p-name', { timeout: 20000 }).catch(function () {});
const first = page.locator('a.p-name').first();
if ((await first.count()) > 0) {
await first.click();
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.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();
}
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);
|