summaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-18 08:27:06 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-18 08:27:06 +0800
commitb4f1136457a08c6ad6ef52d9dc1f83fef050d87c (patch)
tree454427819a1db6ea034b06824383ca7e33d6c019 /frontend
parent45ecb4e9dfe225bc982e9af9315590bcb0783df7 (diff)
downloadstrategy-lab-b4f1136457a08c6ad6ef52d9dc1f83fef050d87c.tar.gz
test(frontend): 真浏览器 UI 溢出与图表渲染验证脚本
[变更性质] 新增验证脚本,非产品代码。 [测试目标] Leader 要求每个页面在真浏览器 375/768/1440 三档下零横向溢出、零 pageerror,且迁移后的 layerchart 图表须在含数据状态下渲染并遵循 同一溢出约束(空态验证无法覆盖)。 [测试方案] scripts/ui-verify.mjs:静态服务 dist + Playwright chromium,按路由× 宽度断言 document overflow=0、pageerror=0、bg/fg 主题成立,输出 overflow-probe.json 与全页截图。scripts/chart-overflow-probe.mjs:以 page.route mock /api(含 /api/auth/me、runs 列表带 result、结果单), 勾选两个已完成回测触发对比曲线,真实渲染 svgs 并断言宽度内渲染。 证据存 artifacts/ui-shadcn/(30 组合全 OK,图表 6 组合全 OK)。 [影响范围] 仅验证工具链,node frontend/scripts/... 手动运行;不影响构建产物。
Diffstat (limited to 'frontend')
-rw-r--r--frontend/scripts/chart-overflow-probe.mjs100
-rw-r--r--frontend/scripts/ui-verify.mjs112
2 files changed, 212 insertions, 0 deletions
diff --git a/frontend/scripts/chart-overflow-probe.mjs b/frontend/scripts/chart-overflow-probe.mjs
new file mode 100644
index 0000000..afab51f
--- /dev/null
+++ b/frontend/scripts/chart-overflow-probe.mjs
@@ -0,0 +1,100 @@
+// Renders runs + results with mocked backend data so layerchart charts mount.
+import { chromium } from 'playwright';
+import http from 'node:http';
+import { createReadStream } from 'node:fs';
+import path from 'node:path';
+
+const dist = path.resolve('dist');
+const shotPrefix = "../artifacts/ui-shadcn/chart-probe";
+const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.woff2': 'font/woff2' };
+const server = http.createServer((req, res) => {
+ const url = req.url || '/';
+ if (url.startsWith('/api')) { res.writeHead(404, { 'content-type': 'application/json' }); res.end('{}'); return; }
+ let file = url.split('?')[0].split('#')[0];
+ if (file === '/' || file === '') file = '/index.html';
+ let responded = false;
+ const send = (fp, isFallback) => {
+ const s = createReadStream(fp);
+ s.on('open', () => { if (responded) return; responded = true; res.writeHead(200, { 'content-type': MIME[path.extname(fp)] || 'application/octet-stream' }); s.pipe(res); });
+ s.on('error', () => { if (!isFallback && !responded) send(path.join(dist, 'index.html'), true); else if (!responded) { responded = true; res.writeHead(404); res.end(); } });
+ };
+ send(path.join(dist, file));
+});
+await new Promise((r) => server.listen(0, '127.0.0.1', r));
+const port = server.address().port;
+
+const RUN_ID1 = '11111111-1111-1111-1111-111111111111';
+const RUN_ID2 = '22222222-2222-2222-2222-222222222222';
+const PROJ = '12345678-aaaa-bbbb-cccc-000000000000';
+
+function makeResult() {
+ const equity = [];
+ for (let i = 0; i < 90; i++) {
+ equity.push({
+ date: new Date(2024, 0, 2 + i).toISOString().slice(0, 10),
+ equity: 100000 * (1 + 0.01 * Math.sin(i / 5) + i * 0.0004),
+ cash: 100000 * (1 - 0.2 + 0.01 * Math.sin(i / 7)),
+ benchmark: 100000 * (1 + i * 0.0002)
+ });
+ }
+ return {
+ status: 'succeeded', engine: { name: 'backtrader', version: '1.9' },
+ metrics: { total_return: 0.0123, annual_return: 0.05, max_drawdown: -0.033, sharpe: 0.9, trade_count: 1, final_equity: 101234.5 },
+ equity,
+ orders: [], trades: [], closed_trades: [], logs: [], warnings: [],
+ elapsed_ms: 1234, peak_rss_kb: 8192, data_manifest_hash: 'deadbeefdeadbeef1234'
+ };
+}
+
+const result = makeResult();
+const run1 = { id: RUN_ID1, project_id: PROJ, version_id: 'v', dataset_id: 'd', status: 'succeeded', config: { capital: 100000, commission: 0.0003, slippage: 0.001 }, created_at: '2026-01-01T00:00:00Z', result };
+const run2 = { ...run1, id: RUN_ID2 };
+const runsList = { items: [run1, run2] };
+
+const browser = await chromium.launch();
+const out = {};
+for (const route of ['runs', 'results']) {
+ for (const w of [375, 768, 1440]) {
+ const ctx = await browser.newContext({ viewport: { width: w, height: w * 2 } });
+ const page = await ctx.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(String(e)));
+ await page.route('**/api/**', (r) => {
+ const u = r.request().url();
+ const respond = (obj) => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(obj) });
+ if (/\/api\/auth\/me/.test(u)) return respond({ user: { id: 'u1', name: 'probe', role: 'user', session_id: 's1' } });
+ if (/\/api\/runs\/[0-9a-f-]+$/.test(u)) return respond(u.includes(RUN_ID2) ? run2 : run1);
+ if (/\/api\/runs/.test(u)) return respond(runsList);
+ if (/\/api\/projects\//.test(u)) return respond({ id: PROJ, name: 'probe', draft_code: 'x=1', draft_generation: 1 });
+ return respond({ items: [] });
+ });
+ const hash = route === 'runs' ? '#/runs' : `#/projects/${PROJ}/results?run=${RUN_ID1}`;
+ await page.goto('http://127.0.0.1:' + port + '/' + hash, { waitUntil: 'networkidle' });
+ if (route === 'runs') {
+ // select up to 2 runs to trigger the comparison chart
+ await page.evaluate(() => {
+ const boxes = document.querySelectorAll('input[type="checkbox"]');
+ for (let k = 0; k < Math.min(2, boxes.length); k++) {
+ boxes[k].click();
+ }
+ });
+ await page.waitForTimeout(500);
+ }
+ await page.waitForTimeout(900);
+ const m = await page.evaluate(() => ({ sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth, svgs: document.querySelectorAll('svg').length }));
+ const overflow = Math.max(0, m.sw - m.cw);
+ await page.screenshot({ path: shotPrefix + '-' + route + '-' + w + '.png', fullPage: true });
+ out[route + '@' + w] = { overflow, svgs: m.svgs, errors };
+ await ctx.close();
+ }
+}
+await browser.close();
+server.close();
+let bad = 0;
+for (const k of Object.keys(out)) {
+ const r = out[k];
+ const ok = r.overflow === 0 && r.errors.length === 0 && r.svgs > 0;
+ console.log((ok ? 'OK ' : 'FAIL ') + k + ' overflow=' + r.overflow + ' svgs=' + r.svgs + ' errors=' + r.errors.length + (r.errors[0] ? ' :: ' + r.errors[0] : ''));
+ if (!ok) bad++;
+}
+process.exit(bad === 0 ? 0 : 1);
diff --git a/frontend/scripts/ui-verify.mjs b/frontend/scripts/ui-verify.mjs
new file mode 100644
index 0000000..1f53e99
--- /dev/null
+++ b/frontend/scripts/ui-verify.mjs
@@ -0,0 +1,112 @@
+// Playwright visual/overflow verification: real browser, three widths,
+// zero pageerror + zero document horizontal overflow asserted per page.
+// Usage: node frontend/scripts/ui-verify.mjs <distDir> <outDir> [routesCsv]
+import { chromium } from 'playwright';
+import { mkdirSync, writeFileSync } from 'node:fs';
+import path from 'node:path';
+import http from 'node:http';
+import { createReadStream } from 'node:fs';
+
+const dist = path.resolve(process.argv[2] || 'dist');
+const outDir = path.resolve(process.argv[3] || '../artifacts/ui-shadcn');
+const routes = (process.argv[4] || 'login,register,reset')
+ .split(',')
+ .map(function (s) { return s.trim(); })
+ .filter(Boolean);
+
+mkdirSync(outDir, { recursive: true });
+
+const MIME = {
+ '.html': 'text/html',
+ '.js': 'text/javascript',
+ '.css': 'text/css',
+ '.svg': 'image/svg+xml',
+ '.png': 'image/png',
+ '.woff2': 'font/woff2',
+ '.json': 'application/json'
+};
+
+// tiny static server for the built dist (hash router friendly, /api 404s)
+const server = http.createServer(function (req, res) {
+ const url = req.url || '/';
+ if (url.startsWith('/api')) {
+ res.writeHead(404, { 'content-type': 'application/json' });
+ res.end(JSON.stringify({ error: { code: 'not_found', message: 'no backend in visual probe' } }));
+ return;
+ }
+ let file = url.split('?')[0].split('#')[0];
+ if (file === '/' || file === '') file = '/index.html';
+ var responded = false;
+ const send = function (fp, isFallback) {
+ const stream = createReadStream(fp);
+ stream.on('open', function () {
+ if (responded) return;
+ responded = true;
+ res.writeHead(200, { 'content-type': MIME[path.extname(fp)] || 'application/octet-stream' });
+ stream.pipe(res);
+ });
+ stream.on('error', function () {
+ if (isFallback) {
+ if (!responded) {
+ responded = true;
+ res.writeHead(404);
+ res.end();
+ }
+ return;
+ }
+ send(path.join(dist, 'index.html'), true);
+ });
+ };
+ send(path.join(dist, file));
+});
+await new Promise(function (res) { server.listen(0, '127.0.0.1', res); });
+const port = server.address().port;
+
+const widths = [375, 768, 1440];
+const results = {};
+const browser = await chromium.launch();
+
+for (const route of routes) {
+ const url = 'http://127.0.0.1:' + port + '/#' + route;
+ for (const w of widths) {
+ const context = await browser.newContext({ viewport: { width: w, height: Math.round(w * 2) } });
+ const page = await context.newPage();
+ const pageErrors = [];
+ page.on('pageerror', function (e) { pageErrors.push(String(e)); });
+ await page.goto(url, { waitUntil: 'networkidle' });
+ await page.waitForTimeout(400);
+ const m = await page.evaluate(function () {
+ return {
+ scrollWidth: document.documentElement.scrollWidth,
+ clientWidth: document.documentElement.clientWidth
+ };
+ });
+ const overflow = Math.max(0, m.scrollWidth - m.clientWidth);
+ const bg = await page.evaluate(function () { return getComputedStyle(document.body).backgroundColor; });
+ const fg = await page.evaluate(function () { return getComputedStyle(document.body).color; });
+ const shot = path.join(outDir, route + '-' + w + '.png');
+ await page.screenshot({ path: shot, fullPage: true });
+ results[route + '@' + w] = {
+ overflow: overflow,
+ clientWidth: m.clientWidth,
+ scrollWidth: m.scrollWidth,
+ pageErrors: pageErrors,
+ bg: bg,
+ fg: fg,
+ shot: path.relative(process.cwd(), shot)
+ };
+ await context.close();
+ }
+}
+
+await browser.close();
+server.close();
+writeFileSync(path.join(outDir, 'overflow-probe.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 && r.bg !== r.fg;
+ console.log((ok ? 'OK ' : 'FAIL ') + k + ': overflow=' + r.overflow + ' pageErrors=' + r.pageErrors.length + ' bg=' + r.bg + ' fg=' + r.fg);
+ if (!ok) bad++;
+}
+process.exit(bad === 0 ? 0 : 1);