diff --git a/.gitignore b/.gitignore index 7abc26c..e3cf4fd 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ e2e/**/*.pdf .run/ .testlogs/ e2e/results/ + +# qa tool generated prompts (ephemeral) +qa/.prompts/ diff --git a/package.json b/package.json index d0324e7..5d0f49e 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,10 @@ "db:reset": "node scripts/db.mjs reset", "db:test": "node scripts/db.mjs test-db", "start:api": "cd backend && go run ./cmd/api", - "start:web": "cd frontend && npm start" + "start:web": "cd frontend && npm start", + "qa": "node scripts/qa-tool.mjs", + "qa:index": "node scripts/qa-tool.mjs index", + "qa:list": "node scripts/qa-tool.mjs list", + "qa:ui": "node scripts/qa-ui.mjs" } } diff --git a/qa/README.md b/qa/README.md index e49cbc4..9cba26f 100644 --- a/qa/README.md +++ b/qa/README.md @@ -37,7 +37,32 @@ exactly those screenshots and API calls, and the report must show them. ## 2. Automate it with Claude Code -From the repo root, use the prompt in +**The quick way — the `qa` tool.** It assembles a *grounded* generation prompt +(your card + the app model + the conventions) so Claude never guesses selectors, +routes, or endpoints: + +```bash +npm run qa:index # build the app-model grounding index (do once / after UI changes) +node scripts/qa-tool.mjs list # list scenario cards +node scripts/qa-tool.mjs prompt BOOK-012 # print the grounded prompt — paste into Claude Code +node scripts/qa-tool.mjs run BOOK-012 # run the card's tests + open the evidence report +``` + +`npm run qa:index` reads the codebase and writes +[`app-model.md`](app-model.md) / `app-model.json`: the routes, page objects, +API endpoints, areas and accounts that actually exist. `prompt` embeds that plus +the card and [`e2e/CLAUDE.md`](../e2e/CLAUDE.md) into one ready-to-paste ask, so +the generated test is correct by construction. `run` wraps `run-tests.ps1` for +the card's layer and opens the evidence. + +Generation stays human-in-the-loop: the tool prepares the grounded ask and runs +the result; you (with Claude Code) write and review the test. + +**Prefer a screen?** `npm run qa:ui` opens a small local web UI (localhost:9500) +over the same commands: pick a card, copy its grounded prompt, hit **Run tests**, +and see the evidence report inline. Same engine, nicer front door. + +**Or by hand:** use the template in [`generate-test.prompt.md`](generate-test.prompt.md) with your card id. Claude reads the card and the conventions in [`e2e/CLAUDE.md`](../e2e/CLAUDE.md) and generates the spec using our page objects and fixtures. diff --git a/qa/app-model.json b/qa/app-model.json new file mode 100644 index 0000000..d46ca15 --- /dev/null +++ b/qa/app-model.json @@ -0,0 +1,288 @@ +{ + "generatedAt": "2026-10-10T09:15:03.203Z", + "baseUrl": "http://localhost:4200", + "auth": { + "type": "basic", + "tenantHeader": "X-Tenant", + "note": "Credentials verified within the named tenant; take accounts from e2e/fixtures/accounts.ts, never hardcode.", + "accounts": [ + { + "name": "SKYLINE_AGENT", + "email": "agent@skyline.test", + "tenant": "skyline", + "role": "agent" + }, + { + "name": "SKYLINE_ADMIN", + "email": "admin@skyline.test", + "tenant": "skyline", + "role": "admin" + }, + { + "name": "GLOBETROTTER_AGENT", + "email": "agent@globetrotter.test", + "tenant": "globetrotter", + "role": "agent" + }, + { + "name": "GLOBETROTTER_ADMIN", + "email": "admin@globetrotter.test", + "tenant": "globetrotter", + "role": "admin" + } + ] + }, + "routes": [ + "/login", + "/dashboard", + "/search", + "/book/:flightId", + "/bookings", + "/bookings/:bookingId" + ], + "areas": [ + { + "spec": "auth.spec.ts", + "title": "Signing in and sessions" + }, + { + "spec": "search.spec.ts", + "title": "Searching for flights" + }, + { + "spec": "booking.spec.ts", + "title": "Booking and cancelling" + }, + { + "spec": "tenant-isolation.spec.ts", + "title": "Keeping agencies separate" + }, + { + "spec": "dashboard.spec.ts", + "title": "Agency dashboard" + }, + { + "spec": "simulation.spec.ts", + "title": "Behaviour when things go wrong" + }, + { + "spec": "responsive.spec.ts", + "title": "Phones and small screens" + } + ], + "api": [ + { + "method": "GET", + "path": "/api/session", + "auth": true + }, + { + "method": "GET", + "path": "/api/flights", + "auth": true + }, + { + "method": "GET", + "path": "/api/flights/{id}", + "auth": true + }, + { + "method": "GET", + "path": "/api/airports", + "auth": true + }, + { + "method": "GET", + "path": "/api/airlines", + "auth": true + }, + { + "method": "GET", + "path": "/api/bookings", + "auth": true + }, + { + "method": "POST", + "path": "/api/bookings", + "auth": true + }, + { + "method": "GET", + "path": "/api/bookings/{id}", + "auth": true + }, + { + "method": "POST", + "path": "/api/bookings/{id}/cancel", + "auth": true + }, + { + "method": "GET", + "path": "/api/dashboard", + "auth": true + }, + { + "method": "GET", + "path": "/api/health", + "auth": false + }, + { + "method": "GET", + "path": "/api/tenants", + "auth": false + } + ], + "pageObjects": [ + { + "file": "e2e/pages/bookings.page.ts", + "classes": [ + "BookingsPage", + "BookingDetailPage" + ], + "locators": [ + "rows", + "emptyState", + "filterAll", + "filterConfirmed", + "filterCancelled", + "confirmationBanner", + "reference", + "statusBadge", + "travellerRows", + "totalPaid", + "cancelButton", + "confirmCancelButton", + "keepButton", + "cancelledNotice" + ], + "methods": [ + "goto()", + "settled()", + "rowFor(reference: string)", + "open(reference: string)", + "filterBy(status: 'all' | 'confirmed' | 'cancelled')", + "bookingReference()", + "cancel(expectedReference: string)" + ] + }, + { + "file": "e2e/pages/checkout.page.ts", + "classes": [ + "CheckoutPage" + ], + "locators": [ + "travellerFieldsets", + "addTraveller", + "contactEmail", + "contactPhone", + "confirm", + "total", + "error", + "seatsLeft" + ], + "methods": [ + "waitUntilReady()", + "totalAmount()", + "fillTraveller(index: number, traveller: Traveller)", + "fillTravellers(travellers: Traveller[])", + "removeTraveller(index: number)", + "confirmBooking()" + ] + }, + { + "file": "e2e/pages/dashboard.page.ts", + "classes": [ + "DashboardPage" + ], + "locators": [ + "greeting", + "tiles", + "recentRows", + "topRoutes" + ], + "methods": [ + "goto()", + "settled()", + "tileValue(label: string)" + ] + }, + { + "file": "e2e/pages/login.page.ts", + "classes": [ + "LoginPage" + ], + "locators": [ + "tenantSelect", + "email", + "password", + "submit", + "error", + "demoChips" + ], + "methods": [ + "goto()", + "useDemoChip(account: Account)", + "fill(account: Account, password = account.password)", + "signIn(account: Account)", + "signInExpectingFailure(account: Account, password: string)" + ] + }, + { + "file": "e2e/pages/search.page.ts", + "classes": [ + "FlightCard", + "SearchPage" + ], + "locators": [ + "root", + "origin", + "destination", + "date", + "cabin", + "travellers", + "searchButton", + "resetButton", + "sort", + "resultSummary", + "results", + "emptyState", + "nextPage", + "prevPage" + ], + "methods": [ + "fare()", + "seatsLeft()", + "flightNumber()", + "durationMinutes()", + "goto()", + "card(index = 0)", + "settled()", + "searchRoute(from: string, to: string)", + "setTravellers(n: number)", + "sortBy(value: 'price' | 'duration' | 'departure')", + "totalResults()", + "cardForFlight(flightNumber: string)" + ] + }, + { + "file": "e2e/pages/shell.page.ts", + "classes": [ + "ShellPage" + ], + "locators": [ + "agencyName", + "agencySlug", + "accountName", + "accountMeta", + "signOut" + ], + "methods": [ + "goToDashboard()", + "goToSearch()", + "goToBookings()", + "signOutAndWait()" + ] + } + ], + "conventions": "e2e/CLAUDE.md" +} \ No newline at end of file diff --git a/qa/app-model.md b/qa/app-model.md new file mode 100644 index 0000000..d96e0e4 --- /dev/null +++ b/qa/app-model.md @@ -0,0 +1,78 @@ +# App model — grounding index for test generation + +> Generated by `npm run qa:index` from the codebase. Do not edit by hand. + +Base URL: `http://localhost:4200` · Auth: basic (header `X-Tenant`) + +## Accounts + +| const | email | tenant | role | +| --- | --- | --- | --- | +| `SKYLINE_AGENT` | agent@skyline.test | skyline | agent | +| `SKYLINE_ADMIN` | admin@skyline.test | skyline | admin | +| `GLOBETROTTER_AGENT` | agent@globetrotter.test | globetrotter | agent | +| `GLOBETROTTER_ADMIN` | admin@globetrotter.test | globetrotter | admin | + +_Password: see `DEMO_PASSWORD` in accounts.ts. Credentials verified within the named tenant; take accounts from e2e/fixtures/accounts.ts, never hardcode._ + +## Routes + +- `/login` +- `/dashboard` +- `/search` +- `/book/:flightId` +- `/bookings` +- `/bookings/:bookingId` + +## Areas (spec file → report area) + +- `auth.spec.ts` — Signing in and sessions +- `search.spec.ts` — Searching for flights +- `booking.spec.ts` — Booking and cancelling +- `tenant-isolation.spec.ts` — Keeping agencies separate +- `dashboard.spec.ts` — Agency dashboard +- `simulation.spec.ts` — Behaviour when things go wrong +- `responsive.spec.ts` — Phones and small screens + +## API endpoints + +| method | path | auth | +| --- | --- | --- | +| GET | `/api/session` | required | +| GET | `/api/flights` | required | +| GET | `/api/flights/{id}` | required | +| GET | `/api/airports` | required | +| GET | `/api/airlines` | required | +| GET | `/api/bookings` | required | +| POST | `/api/bookings` | required | +| GET | `/api/bookings/{id}` | required | +| POST | `/api/bookings/{id}/cancel` | required | +| GET | `/api/dashboard` | required | +| GET | `/api/health` | public | +| GET | `/api/tenants` | public | + +## Page objects (use these — no raw selectors in specs) + +### `e2e/pages/bookings.page.ts` — BookingsPage, BookingDetailPage +- locators: `rows`, `emptyState`, `filterAll`, `filterConfirmed`, `filterCancelled`, `confirmationBanner`, `reference`, `statusBadge`, `travellerRows`, `totalPaid`, `cancelButton`, `confirmCancelButton`, `keepButton`, `cancelledNotice` +- methods: `goto()`, `settled()`, `rowFor(reference: string)`, `open(reference: string)`, `filterBy(status: 'all' | 'confirmed' | 'cancelled')`, `bookingReference()`, `cancel(expectedReference: string)` + +### `e2e/pages/checkout.page.ts` — CheckoutPage +- locators: `travellerFieldsets`, `addTraveller`, `contactEmail`, `contactPhone`, `confirm`, `total`, `error`, `seatsLeft` +- methods: `waitUntilReady()`, `totalAmount()`, `fillTraveller(index: number, traveller: Traveller)`, `fillTravellers(travellers: Traveller[])`, `removeTraveller(index: number)`, `confirmBooking()` + +### `e2e/pages/dashboard.page.ts` — DashboardPage +- locators: `greeting`, `tiles`, `recentRows`, `topRoutes` +- methods: `goto()`, `settled()`, `tileValue(label: string)` + +### `e2e/pages/login.page.ts` — LoginPage +- locators: `tenantSelect`, `email`, `password`, `submit`, `error`, `demoChips` +- methods: `goto()`, `useDemoChip(account: Account)`, `fill(account: Account, password = account.password)`, `signIn(account: Account)`, `signInExpectingFailure(account: Account, password: string)` + +### `e2e/pages/search.page.ts` — FlightCard, SearchPage +- locators: `root`, `origin`, `destination`, `date`, `cabin`, `travellers`, `searchButton`, `resetButton`, `sort`, `resultSummary`, `results`, `emptyState`, `nextPage`, `prevPage` +- methods: `fare()`, `seatsLeft()`, `flightNumber()`, `durationMinutes()`, `goto()`, `card(index = 0)`, `settled()`, `searchRoute(from: string, to: string)`, `setTravellers(n: number)`, `sortBy(value: 'price' | 'duration' | 'departure')`, `totalResults()`, `cardForFlight(flightNumber: string)` + +### `e2e/pages/shell.page.ts` — ShellPage +- locators: `agencyName`, `agencySlug`, `accountName`, `accountMeta`, `signOut` +- methods: `goToDashboard()`, `goToSearch()`, `goToBookings()`, `signOutAndWait()` diff --git a/scripts/qa-tool.mjs b/scripts/qa-tool.mjs new file mode 100644 index 0000000..8778b49 --- /dev/null +++ b/scripts/qa-tool.mjs @@ -0,0 +1,311 @@ +#!/usr/bin/env node +/** + * QA test-generation helper (Phase 1: the natural-language door). + * + * The loop it supports: + * 1. index build the app-model index — a grounded map of the app (routes, + * page objects, API endpoints, areas, accounts) so generation does + * not guess selectors or contracts. + * 2. prompt assemble a complete, grounded generation prompt for a scenario + * card (card + app model + conventions). Paste it into Claude Code + * to generate the Playwright test. + * 3. run run the test(s) for a card and open the evidence report. + * + * node scripts/qa-tool.mjs index + * node scripts/qa-tool.mjs prompt BOOK-012 + * node scripts/qa-tool.mjs run BOOK-012 + * node scripts/qa-tool.mjs list + * + * Nothing here calls an AI directly — generation stays human-in-the-loop in + * Claude Code. This tool prepares the grounded ask and runs the result. + */ + +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const rel = (p) => join(ROOT, p); +const read = (p) => (existsSync(rel(p)) ? readFileSync(rel(p), 'utf8') : ''); + +// --- parsers: build the app model from source ------------------------------- + +function parseRoutes() { + const src = read('frontend/src/app/app.routes.ts'); + const routes = []; + for (const m of src.matchAll(/path:\s*'([^']*)'/g)) { + const path = m[1]; + if (path === '' || path === '**') continue; + routes.push('/' + path.replace(/^\/+/, '')); + } + return [...new Set(routes)]; +} + +function parseAccounts() { + const src = read('e2e/fixtures/accounts.ts'); + const accounts = []; + const re = /export const (\w+):\s*Account\s*=\s*\{([\s\S]*?)\};/g; + for (const m of src.matchAll(re)) { + const body = m[2]; + const g = (k) => (body.match(new RegExp(`${k}:\\s*'([^']*)'`)) || [])[1]; + accounts.push({ name: m[1], email: g('email'), tenant: g('tenant'), role: g('role') }); + } + return accounts; +} + +function parseAreas() { + const src = read('e2e/reporters/report-model.ts'); + const areas = []; + const re = /'([\w-]+\.spec\.ts)':\s*\{\s*title:\s*'([^']*)'/g; + for (const m of src.matchAll(re)) areas.push({ spec: m[1], title: m[2] }); + return areas; +} + +function parseApi() { + const src = read('backend/internal/httpapi/router.go'); + const api = []; + // protected.* routes need auth; root.* (health, tenants) are public. + for (const m of src.matchAll(/(protected|root)\.HandleFunc\("(\w+)\s+(\/api\/[^"]*)"/g)) { + api.push({ method: m[2], path: m[3], auth: m[1] === 'protected' }); + } + return api; +} + +function parsePageObjects() { + const dir = rel('e2e/pages'); + if (!existsSync(dir)) return []; + const objects = []; + for (const file of readdirSync(dir).filter((f) => f.endsWith('.page.ts'))) { + const src = readFileSync(join(dir, file), 'utf8'); + const classes = [...src.matchAll(/export class (\w+)/g)].map((m) => m[1]); + const locators = [...src.matchAll(/readonly (\w+):\s*Locator/g)].map((m) => m[1]); + // Public methods: skip the constructor and private helpers. + const methods = [...src.matchAll(/^\s{2}(?:async\s+)?([a-z]\w*)\s*\(([^)]*)\)/gm)] + .map((m) => `${m[1]}(${m[2].trim()})`) + .filter((sig) => !sig.startsWith('constructor(')); + objects.push({ file: `e2e/pages/${file}`, classes, locators, methods }); + } + return objects; +} + +export function buildModel() { + return { + generatedAt: new Date().toISOString(), + baseUrl: 'http://localhost:4200', + auth: { + type: 'basic', + tenantHeader: 'X-Tenant', + note: 'Credentials verified within the named tenant; take accounts from e2e/fixtures/accounts.ts, never hardcode.', + accounts: parseAccounts(), + }, + routes: parseRoutes(), + areas: parseAreas(), + api: parseApi(), + pageObjects: parsePageObjects(), + conventions: 'e2e/CLAUDE.md', + }; +} + +// --- index: write app-model.json + app-model.md ----------------------------- + +function renderModelMd(m) { + const L = []; + L.push('# App model — grounding index for test generation', ''); + L.push('> Generated by `npm run qa:index` from the codebase. Do not edit by hand.', ''); + L.push(`Base URL: \`${m.baseUrl}\` · Auth: ${m.auth.type} (header \`${m.auth.tenantHeader}\`)`, ''); + + L.push('## Accounts', '', '| const | email | tenant | role |', '| --- | --- | --- | --- |'); + for (const a of m.auth.accounts) L.push(`| \`${a.name}\` | ${a.email} | ${a.tenant} | ${a.role} |`); + L.push('', `_Password: see \`DEMO_PASSWORD\` in accounts.ts. ${m.auth.note}_`, ''); + + L.push('## Routes', ''); + for (const r of m.routes) L.push(`- \`${r}\``); + L.push(''); + + L.push('## Areas (spec file → report area)', ''); + for (const a of m.areas) L.push(`- \`${a.spec}\` — ${a.title}`); + L.push(''); + + L.push('## API endpoints', '', '| method | path | auth |', '| --- | --- | --- |'); + for (const e of m.api) L.push(`| ${e.method} | \`${e.path}\` | ${e.auth ? 'required' : 'public'} |`); + L.push(''); + + L.push('## Page objects (use these — no raw selectors in specs)', ''); + for (const po of m.pageObjects) { + L.push(`### \`${po.file}\` — ${po.classes.join(', ')}`); + if (po.locators.length) L.push(`- locators: ${po.locators.map((x) => `\`${x}\``).join(', ')}`); + if (po.methods.length) L.push(`- methods: ${po.methods.map((x) => `\`${x}\``).join(', ')}`); + L.push(''); + } + return L.join('\n'); +} + +export function writeIndex() { + const m = buildModel(); + writeFileSync(rel('qa/app-model.json'), JSON.stringify(m, null, 2), 'utf8'); + writeFileSync(rel('qa/app-model.md'), renderModelMd(m), 'utf8'); + return m; +} + +function cmdIndex() { + const m = writeIndex(); + console.log('✓ wrote qa/app-model.json and qa/app-model.md'); + console.log( + ` ${m.routes.length} routes · ${m.api.length} endpoints · ` + + `${m.pageObjects.length} page objects · ${m.auth.accounts.length} accounts`, + ); +} + +// --- scenario cards --------------------------------------------------------- + +function cardPath(id) { + const dir = rel('qa/scenarios'); + const hit = existsSync(dir) && readdirSync(dir).find((f) => f.toUpperCase().startsWith(id.toUpperCase())); + return hit ? join(dir, hit) : null; +} + +export function readCard(id) { + const p = cardPath(id); + if (!p) return null; + const text = readFileSync(p, 'utf8'); + const field = (k) => (text.match(new RegExp(`^${k}:\\s*(.+)$`, 'm')) || [])[1]?.trim(); + return { + path: p, + file: p.split(/[\\/]/).pop(), + text, + id: field('id') || id, + layer: field('layer') || 'ui', + title: field('title') || id, + area: field('area') || '', + risk: field('risk') || '', + }; +} + +/** All scenario cards as structured objects, sorted by filename. */ +export function listCards() { + const dir = rel('qa/scenarios'); + const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.yml')).sort() : []; + return files.map((f) => readCard(f.replace(/\.yml$/, ''))).filter(Boolean); +} + +function cmdList() { + const cards = listCards(); + if (!cards.length) { + console.log('No scenario cards yet. Copy qa/scenario-template.yml to qa/scenarios/.yml'); + return; + } + console.log('Scenario cards:'); + for (const c of cards) console.log(` ${c.file.padEnd(34)} [${c.layer}] ${c.title}`); +} + +// --- prompt: assemble the grounded generation ask --------------------------- + +/** Assemble the grounded generation prompt for a card. Pure: returns a string. */ +export function buildPrompt(id) { + if (!existsSync(rel('qa/app-model.md'))) writeIndex(); + const card = readCard(id); + if (!card) throw new Error(`No scenario card for "${id}" in qa/scenarios/.`); + const conventions = read('e2e/CLAUDE.md'); + const appModel = read('qa/app-model.md'); + + return [ + `# Generate the Playwright test for scenario ${id}`, + '', + 'You are generating an end-to-end test for the TravelHub app. Use ONLY the', + 'page objects, routes, accounts and endpoints in the app model below — do not', + 'invent selectors or URLs. Follow the conventions exactly.', + '', + '## Scenario card', + '', + '```yaml', + card.text.trim(), + '```', + '', + '## App model (grounding — what actually exists)', + '', + appModel.trim(), + '', + '## Conventions (e2e/CLAUDE.md)', + '', + conventions.trim(), + '', + '## Your task', + '', + `- Add the test to the spec for this card's area; reuse the \`test.describe\` block.`, + '- Drive the UI only through the page objects above; add a method if one is missing.', + `- For every \`then:\` clause and every \`evidence:\` item, add an \`evidence.step(name, {...})\``, + ' capturing a screenshot and the observed figures.', + '- No raw selectors, no `waitForTimeout`, no hardcoded secrets, no weakened assertions.', + '- Clean up any data the test creates.', + '', + 'Then show the diff, run it, and confirm the evidence report shows the required', + `screenshots and API request/response:`, + '', + ` node scripts/qa-tool.mjs run ${id}`, + '', + 'If the app behaves differently from the card, stop and report the difference —', + 'that is a finding, not something to code around.', + ].join('\n'); +} + +function cmdPrompt(id) { + const card = readCard(id); + if (!card) { console.error(`No scenario card for "${id}". Run: node scripts/qa-tool.mjs list`); process.exit(1); } + const prompt = buildPrompt(id); + mkdirSync(rel('qa/.prompts'), { recursive: true }); + writeFileSync(rel(`qa/.prompts/${id}.prompt.md`), prompt, 'utf8'); + console.log(`✓ grounded prompt written to qa/.prompts/${id}.prompt.md`); + console.log(' Paste it into Claude Code (or open the file) to generate the test.\n'); + console.log(prompt); +} + +// --- run: execute the card's layer and open evidence ------------------------ + +/** Map a card's layer to a run-tests.ps1 layer. */ +export function layerFor(card) { + return ({ backend: 'backend', api: 'api', ui: 'ui', binding: 'binding' })[card.layer] || 'ui'; +} + +function cmdRun(id, extra) { + const card = readCard(id); + if (!card) { console.error(`No scenario card for "${id}".`); process.exit(1); } + const layer = layerFor(card); + + // Grep on a distinctive slice of the card title so only this scenario runs. + const grep = extra.grep || card.title.split(/\s+/).slice(0, 4).join(' '); + + const psArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', 'run-tests.ps1', + '-Layer', layer, '-Grep', grep, '-Open']; + + console.log(`Running ${id} [layer: ${layer}] matching "${grep}" ...`); + if (process.platform !== 'win32') { + console.log(`(non-Windows) run manually:\n pwsh ${psArgs.join(' ')}`); + return; + } + const r = spawnSync('powershell', psArgs, { cwd: ROOT, stdio: 'inherit' }); + process.exit(r.status ?? 1); +} + +// --- dispatch (only when run directly, not when imported) ------------------- + +const runDirectly = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url; +if (runDirectly) main(); + +function main() { +const [cmd, arg, ...rest] = process.argv.slice(2); +const extra = { grep: (rest.includes('--grep') ? rest[rest.indexOf('--grep') + 1] : null) }; + +switch (cmd) { + case 'index': cmdIndex(); break; + case 'list': cmdList(); break; + case 'prompt': if (!arg) { console.error('usage: qa-tool.mjs prompt '); process.exit(1); } cmdPrompt(arg); break; + case 'run': if (!arg) { console.error('usage: qa-tool.mjs run [--grep "..."]'); process.exit(1); } cmdRun(arg, extra); break; + default: + console.log('TravelHub QA tool — Phase 1 (natural-language door)\n'); + console.log(' node scripts/qa-tool.mjs index build the app-model grounding index'); + console.log(' node scripts/qa-tool.mjs list list scenario cards'); + console.log(' node scripts/qa-tool.mjs prompt assemble the grounded generation prompt'); + console.log(' node scripts/qa-tool.mjs run run the card\'s tests + open evidence'); +} +} diff --git a/scripts/qa-ui.mjs b/scripts/qa-ui.mjs new file mode 100644 index 0000000..3ab3ef0 --- /dev/null +++ b/scripts/qa-ui.mjs @@ -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 = ` + +TravelHub QA tool + + +
+

TravelHub QA tool

+ scenario → grounded prompt → run → evidence + +
+
+
+
Select a scenario card on the left.
+
+ +`;