new changes
This commit is contained in:
@@ -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 → grounded prompt → run → 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=>({'&':'&','<':'<','>':'>','"':'"'}[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> · area '+esc(c.area||'—')+' · 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>`;
|
||||
Reference in New Issue
Block a user