new changes

This commit is contained in:
nilesh.p
2026-10-10 21:57:24 +05:30
parent 56a4b44db8
commit 767127617d
7 changed files with 942 additions and 2 deletions
+231
View File
@@ -0,0 +1,231 @@
#!/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>`;