Files
TS_Proto/scripts/qa-ui.mjs
T
2026-10-10 21:57:24 +05:30

232 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* QA tool UI — a small local web page over the Phase-1 `qa` commands.
*
* Pick a scenario card, see the grounded generation prompt (copy it into Claude
* Code), run the card's tests, and view the evidence inline. It wraps the exact
* same functions the CLI uses (scripts/qa-tool.mjs), so there is one engine.
*
* node scripts/qa-ui.mjs http://localhost:9500
* node scripts/qa-ui.mjs --port 8200 --no-open
*
* Local dev tool: binds localhost only. The /run endpoint executes the test
* runner, so do not expose this port beyond your machine.
*/
import { createServer } from 'node:http';
import { createReadStream, existsSync, statSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { extname, join, normalize } from 'node:path';
import { pathToFileURL } from 'node:url';
import { ROOT, buildPrompt, layerFor, listCards, readCard, writeIndex } from './qa-tool.mjs';
const args = process.argv.slice(2);
const PORT = args.includes('--port') ? Number(args[args.indexOf('--port') + 1]) : 9500;
const shouldOpen = !args.includes('--no-open');
const EVIDENCE_DIR = join(ROOT, 'e2e', 'evidence-report');
const TYPES = {
'.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript',
'.json': 'application/json', '.png': 'image/png', '.md': 'text/markdown; charset=utf-8',
};
function sendJson(res, code, body) {
const s = JSON.stringify(body);
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(s) });
res.end(s);
}
// Run run-tests.ps1 for a card, buffering output; resolve when it exits.
function runCard(id) {
const card = readCard(id);
if (!card) return Promise.resolve({ ok: false, output: `No card "${id}".` });
const layer = layerFor(card);
const grep = card.title.split(/\s+/).slice(0, 4).join(' ');
return new Promise((resolve) => {
const ps = spawn(
'powershell',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', 'run-tests.ps1',
'-Layer', layer, '-Grep', grep],
{ cwd: ROOT },
);
let out = '';
ps.stdout.on('data', (d) => (out += d));
ps.stderr.on('data', (d) => (out += d));
ps.on('close', (code) => resolve({ ok: code === 0, exitCode: code, layer, grep, output: out }));
ps.on('error', (e) => resolve({ ok: false, output: String(e) }));
});
}
const server = createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
const path = url.pathname;
try {
if (path === '/') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(PAGE); }
if (path === '/qa/cards') return sendJson(res, 200, listCards());
if (path === '/qa/index') { const m = writeIndex(); return sendJson(res, 200, { ok: true, counts: {
routes: m.routes.length, api: m.api.length, pageObjects: m.pageObjects.length, accounts: m.auth.accounts.length } }); }
const pm = path.match(/^\/qa\/prompt\/([\w-]+)$/);
if (pm) {
try { return sendJson(res, 200, { prompt: buildPrompt(pm[1]) }); }
catch (e) { return sendJson(res, 404, { error: String(e.message || e) }); }
}
const rm = path.match(/^\/qa\/run\/([\w-]+)$/);
if (rm && req.method === 'POST') return sendJson(res, 200, await runCard(rm[1]));
// Serve the evidence report for the inline iframe.
if (path.startsWith('/evidence/')) {
const target = join(EVIDENCE_DIR, normalize(path.slice('/evidence/'.length)) || 'index.html');
if (!target.startsWith(EVIDENCE_DIR) || !existsSync(target) || statSync(target).isDirectory()) {
res.writeHead(404); return res.end('not found');
}
res.writeHead(200, { 'Content-Type': TYPES[extname(target).toLowerCase()] || 'application/octet-stream' });
return createReadStream(target).pipe(res);
}
res.writeHead(404); res.end('not found');
} catch (e) {
sendJson(res, 500, { error: String(e.message || e) });
}
});
server.on('error', (e) => {
if (e.code === 'EADDRINUSE') { console.error(`Port ${PORT} in use. Try: node scripts/qa-ui.mjs --port ${PORT + 1}`); process.exit(1); }
throw e;
});
server.listen(PORT, () => {
const url = `http://localhost:${PORT}/`;
console.log(`\n QA tool UI: ${url}\n Ctrl+C to stop.\n`);
if (shouldOpen) {
const [c, a] = process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
: process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
spawn(c, a, { detached: true, stdio: 'ignore' }).unref();
}
});
// --- the page (self-contained, no external resources) -----------------------
const PAGE = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>TravelHub QA tool</title>
<style>
:root{--ink:#111827;--muted:#6b7280;--faint:#9ca3af;--line:#e5e7eb;--bg:#f3f4f6;--card:#fff;
--accent:#0b5cd6;--ok:#067a6e;--ok-bg:#e6f4f1;--fail:#b3243f;--fail-bg:#fdeaee;}
*{box-sizing:border-box}
body{margin:0;font:14px/1.55 'Inter',-apple-system,'Segoe UI',Roboto,sans-serif;color:var(--ink);background:var(--bg)}
header{background:#0b1727;color:#fff;padding:14px 20px;display:flex;align-items:center;gap:14px}
header h1{font-size:1.05rem;margin:0}
header .sub{color:#9fb3cd;font-size:.8rem}
header button{margin-left:auto}
.wrap{display:grid;grid-template-columns:300px 1fr;gap:0;height:calc(100vh - 50px)}
.list{border-right:1px solid var(--line);overflow:auto;background:var(--card)}
.card{padding:12px 16px;border-bottom:1px solid var(--line);cursor:pointer}
.card:hover{background:#f8fafc}
.card.active{background:var(--ok-bg);border-left:3px solid var(--ok)}
.card .id{font-weight:650;font-size:.82rem}
.card .title{color:var(--muted);font-size:.82rem}
.card .tag{display:inline-block;font-size:.64rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;
padding:1px 6px;border-radius:4px;background:#eef2f7;color:var(--muted);margin-top:4px}
.main{overflow:auto;padding:20px 24px}
.empty{color:var(--faint);padding:40px;text-align:center}
h2{font-size:1.1rem;margin:0 0 4px}
.meta{color:var(--muted);font-size:.82rem;margin-bottom:14px}
.panel{background:var(--card);border:1px solid var(--line);border-radius:8px;margin-bottom:16px}
.panel h3{font-size:.72rem;text-transform:uppercase;letter-spacing:.06em;color:var(--faint);
margin:0;padding:11px 14px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px}
.panel .body{padding:0}
pre{margin:0;padding:14px;background:#0f172a;color:#e2e8f0;border-radius:0 0 8px 8px;overflow:auto;
font:12px/1.5 ui-monospace,Menlo,monospace;max-height:360px;white-space:pre-wrap}
pre.card-yaml{background:#f8fafc;color:var(--ink);max-height:none}
button{font:inherit;font-size:.82rem;border:1px solid #cfd9e8;background:#fff;color:var(--ink);
border-radius:6px;padding:6px 12px;cursor:pointer}
button:hover{border-color:var(--accent);color:var(--accent)}
button.primary{background:var(--accent);border-color:var(--accent);color:#fff}
button.primary:hover{background:#0a4fb8;color:#fff}
button:disabled{opacity:.5;cursor:not-allowed}
.copy{margin-left:auto}
.run-status{font-weight:650;font-size:.8rem}
.run-status.ok{color:var(--ok)} .run-status.fail{color:var(--fail)}
iframe{width:100%;height:520px;border:1px solid var(--line);border-radius:8px;background:#fff}
.hint{color:var(--faint);font-size:.8rem;padding:8px 14px}
</style></head>
<body>
<header>
<h1>TravelHub QA tool</h1>
<span class="sub">scenario &rarr; grounded prompt &rarr; run &rarr; evidence</span>
<button id="reindex">Refresh app model</button>
</header>
<div class="wrap">
<div class="list" id="list"></div>
<div class="main" id="main"><div class="empty">Select a scenario card on the left.</div></div>
</div>
<script>
let cards=[], current=null;
async function load(){
cards = await (await fetch('/qa/cards')).json();
const el=document.getElementById('list');
if(!cards.length){ el.innerHTML='<div class="empty">No scenario cards.<br>Add one in qa/scenarios/.</div>'; return; }
el.innerHTML = cards.map(c=>
'<div class="card" data-id="'+c.id+'"><div class="id">'+c.id+'</div>'+
'<div class="title">'+esc(c.title)+'</div><span class="tag">'+c.layer+'</span></div>').join('');
el.querySelectorAll('.card').forEach(d=>d.onclick=()=>select(d.dataset.id));
}
function esc(s){return String(s).replace(/[&<>"]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m]));}
async function select(id){
current=id;
document.querySelectorAll('.card').forEach(d=>d.classList.toggle('active',d.dataset.id===id));
const c=cards.find(x=>x.id===id);
const main=document.getElementById('main');
main.innerHTML=
'<h2>'+esc(c.id)+' — '+esc(c.title)+'</h2>'+
'<div class="meta">layer <b>'+c.layer+'</b> &middot; area '+esc(c.area||'—')+' &middot; risk '+esc(c.risk||'—')+'</div>'+
'<div class="panel"><h3>Scenario card</h3><div class="body"><pre class="card-yaml">'+esc(c.text)+'</pre></div></div>'+
'<div class="panel"><h3>Grounded prompt'+
'<button class="copy" id="copyBtn">Copy</button></h3>'+
'<div class="hint">Paste into Claude Code to generate the Playwright test (grounded in the real app model).</div>'+
'<div class="body"><pre id="prompt">loading…</pre></div></div>'+
'<div class="panel"><h3>Run'+
'<button class="primary" id="runBtn" style="margin-left:auto">Run tests</button>'+
'<span class="run-status" id="runStatus"></span></h3>'+
'<div class="body"><pre id="runOut" style="max-height:220px">Not run yet.</pre></div></div>'+
'<div class="panel"><h3>Evidence report</h3><div class="body" style="padding:10px">'+
'<iframe id="evidence" src="/evidence/index.html?t='+Date.now()+'"></iframe></div></div>';
const pr=await (await fetch('/qa/prompt/'+id)).json();
document.getElementById('prompt').textContent = pr.prompt || pr.error || '(none)';
document.getElementById('copyBtn').onclick=()=>navigator.clipboard.writeText(document.getElementById('prompt').textContent);
document.getElementById('runBtn').onclick=()=>run(id);
}
async function run(id){
const btn=document.getElementById('runBtn'), st=document.getElementById('runStatus'), out=document.getElementById('runOut');
btn.disabled=true; st.textContent='running…'; st.className='run-status'; out.textContent='Running — this can take ~10–30s…';
try{
const r=await (await fetch('/qa/run/'+id,{method:'POST'})).json();
st.textContent = r.ok?'passed':'failed'; st.className='run-status '+(r.ok?'ok':'fail');
out.textContent = r.output || '(no output)';
document.getElementById('evidence').src='/evidence/index.html?t='+Date.now();
}catch(e){ st.textContent='error'; st.className='run-status fail'; out.textContent=String(e); }
btn.disabled=false;
}
document.getElementById('reindex').onclick=async()=>{
const r=await (await fetch('/qa/index')).json();
alert('App model rebuilt: '+r.counts.routes+' routes, '+r.counts.api+' endpoints, '+
r.counts.pageObjects+' page objects, '+r.counts.accounts+' accounts.');
if(current) select(current);
};
load();
</script>
</body></html>`;