diff options
Diffstat (limited to 'frontend/scripts')
| -rw-r--r-- | frontend/scripts/chart-overflow-probe.mjs | 100 | ||||
| -rw-r--r-- | frontend/scripts/ui-verify.mjs | 112 |
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); |
