diff --git a/public/agent.html b/public/agent.html index a58e2ef..7802dd5 100644 --- a/public/agent.html +++ b/public/agent.html @@ -1,210 +1,243 @@ - + - - - - aiwright — QA Agent - - - - -
-
- -
-
aiwright QA Agent
-
Give it a story; it plans and runs the pipeline itself — pausing for your OK - on side-effecting steps and stopping on a real regression. · - Step-by-step studio →
-
-
- -
-

User story

- -
- - -
-
+ + + + NeuralQA · Agent + + + + + + + +
+ + +
+
+ ✦ Autonomous mode +

Give it a story. The agent runs the pipeline itself.

+

It plans and sequences every step — design, inspect, generate, verify, run, heal — pausing for your OK on side-effecting steps and stopping on a real regression instead of faking green.

+
+ +
+

User story

+ +
+ + +
+
-
-
- - - + + function onEvent(e) { + switch (e.type) { + case 'start': + line(`goal: ${esc(e.goal)}`, 'muted'); break; + case 'plan': + line(`🧠 ${esc(e.text)}`, 'plan'); break; + case 'tool': + line(`▶ ${e.tool} ${e.decision} ${esc(e.reason)}` + + (e.input && Object.keys(e.input).length ? ` ${esc(JSON.stringify(e.input))}` : '')); break; + case 'awaiting-approval': + showGate('approve', `"${e.tool}" — ${e.reason}`, [ + { label: 'Approve', value: 'yes' }, + { label: 'Decline', value: 'no', cls: 'ghost' }, + { label: 'Abort run', value: 'abort', cls: 'bad' } + ]); break; + case 'declined': + line(` ✗ declined "${e.tool}"`, 'muted'); break; + case 'result': + line(` ${e.ok ? '✓' : '✗'} ${esc(e.summary)}`, e.ok ? 'ok' : 'bad'); break; + case 'error': + line(` ✗ error (${e.tool}): ${esc(e.message)}`, 'bad'); break; + case 'escalation': + line(`⚠ ESCALATION: ${esc(e.reason)}`, 'bad'); + showGate('escalate', e.reason, [ + { label: 'Halt for triage', value: 'halt', cls: 'bad' }, + { label: 'Continue anyway', value: 'continue', cls: 'warn' } + ]); break; + case 'done': + hideGate(); + line(`■ done — ${e.outcome}${e.statePath ? ' · ' + esc(e.statePath) : ''}`, e.outcome === 'completed' ? 'ok' : 'bad'); + statusEl.textContent = `Finished: ${e.outcome}`; + startBtn.disabled = false; + break; + } + } + + startBtn.onclick = async () => { + const story = storyEl.value.trim(); + if (!story) { statusEl.textContent = 'Enter a story first.'; return; } + startBtn.disabled = true; + statusEl.innerHTML = 'Starting…'; + logEl.innerHTML = ''; + runPanel.classList.remove('hidden'); + try { + const r = await fetch('/api/agent/start', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ story }) + }); + const data = await r.json(); + if (!r.ok) throw new Error(data.error || 'failed to start'); + runId = data.runId; + statusEl.innerHTML = 'Running…'; + const src = new EventSource(`/api/agent/${runId}/events`); + src.onmessage = (m) => onEvent(JSON.parse(m.data)); + src.onerror = () => src.close(); + } catch (err) { + statusEl.textContent = 'Error: ' + err.message; + startBtn.disabled = false; + } + }; + + diff --git a/public/favicon.svg b/public/favicon.svg index 8936f4f..b1e28c8 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1,11 +1,15 @@ - + - - - + + + - - - + + + + + + + diff --git a/public/scenarios.html b/public/scenarios.html new file mode 100644 index 0000000..5a61ef9 --- /dev/null +++ b/public/scenarios.html @@ -0,0 +1,434 @@ + + + + + + NeuralQA · Scenario Studio + + + + + + + +
+ + +
+
+ ✦ AI-grounded test design +

From your app to test scenarios
in seconds.

+

Point NeuralQA at your site or API, share your stories, then pick the scenarios and generate a runnable suite — grounded in the real system, not guesses.

+
+
1 Connect
+ +
2 Select
+ +
3 Generate
+
+
+ +
+
+
+ + Site URL · UI, optional +
+ +
+
+
+ + OpenAPI / Swagger spec · API, optional +
+ +
+
+
+ + User stories / requirements · optional +
+ +
Provide a site URL, an API spec, or stories — at least one.
+
+
+ + +
+
+ +
+
+ +
+
0 selectedPick the scenarios you want, then generate the suite.
+ +
+
+ + + + diff --git a/src/ai/prompts.ts b/src/ai/prompts.ts index a31718a..4ae5974 100644 --- a/src/ai/prompts.ts +++ b/src/ai/prompts.ts @@ -220,6 +220,18 @@ Produce: Be specific to THIS story; avoid generic boilerplate. Quality of judgement over quantity.`; +// Appended to a DESIGN request when the real system was observed, so scenarios reference its +// ACTUAL UI flows/elements and/or API endpoints instead of generic guesses. +export const SITE_CONTEXT_INSTRUCTION = ` +A LIVE CONTEXT follows: what was observed from the real system under test — UI elements inspected +from the page and/or API endpoints parsed from its OpenAPI spec. Ground the test design in it: +- Tie scenarios to the actual flows/elements and endpoints present (the real search box, login + form, GET /orders, the declared status codes) rather than inventing capabilities the system does + not expose. +- If the story mentions something the system does not expose, surface it as an open question. +- Still lead with the user story's intent — the context sharpens the scenarios, it does not replace + the requirement.`; + // Appended to the generation request when a human-approved test design is supplied. // The curated design becomes the source of truth for WHICH scenarios to implement. export const DESIGN_SCOPED_INSTRUCTION = ` diff --git a/src/ai/specProbe.ts b/src/ai/specProbe.ts index c9a6d91..c94a3b1 100644 --- a/src/ai/specProbe.ts +++ b/src/ai/specProbe.ts @@ -68,15 +68,33 @@ export interface ProbeOptions { rootDir?: string; } -/** Short, human-readable schema name for a response object (resolves $ref / array items). */ +/** + * Short, human-readable schema name for a response object (resolves $ref / array items). + * Handles both OpenAPI 3.x (response.content['application/json'].schema) and Swagger 2.0 + * (response.schema directly). + */ function schemaName(responseObj: any): string | undefined { - const schema = responseObj?.content?.['application/json']?.schema; + const schema = responseObj?.content?.['application/json']?.schema ?? responseObj?.schema; if (!schema) return undefined; if (schema.$ref) return schema.$ref.split('/').pop(); if (schema.type === 'array' && schema.items?.$ref) return `${schema.items.$ref.split('/').pop()}[]`; + if (schema.type === 'array' && schema.items?.type) return `${schema.items.type}[]`; return schema.type; } +/** + * Resolves the base URL from a spec: OpenAPI 3.x uses servers[0].url; Swagger 2.0 builds it from + * schemes + host + basePath. Falls back to the configured apiBaseUrl when neither is present. + */ +function baseUrlFromSpec(spec: any, fallback: string): string { + if (spec.servers?.[0]?.url) return spec.servers[0].url; + if (spec.host) { + const scheme = (Array.isArray(spec.schemes) && spec.schemes[0]) || 'https'; + return `${scheme}://${spec.host}${spec.basePath ?? ''}`; + } + return fallback; +} + const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete']; export async function probeApi(specPath: string, opts: ProbeOptions = {}): Promise { @@ -95,8 +113,10 @@ export async function probeApi(specPath: string, opts: ProbeOptions = {}): Promi } const warnings: string[] = []; - const baseUrl = - opts.baseUrl ?? spec.servers?.[0]?.url ?? config.apiBaseUrl; + const baseUrl = opts.baseUrl ?? baseUrlFromSpec(spec, config.apiBaseUrl); + if (spec.swagger && !spec.openapi) { + warnings.push(`Swagger 2.0 spec detected (v${spec.swagger}); parsed for endpoints/schemas.`); + } const endpoints: EndpointEntry[] = []; for (const [p, pathItem] of Object.entries(spec.paths ?? {})) { @@ -109,11 +129,12 @@ export async function probeApi(specPath: string, opts: ProbeOptions = {}): Promi operationId: op.operationId, summary: op.summary, params: (op.parameters ?? []).map((pr: any) => ({ + // OpenAPI 3.x: type/example under pr.schema; Swagger 2.0: type/default on the param itself. name: pr.name, in: pr.in, required: !!pr.required, - type: pr.schema?.type, - example: pr.schema?.example ?? pr.example + type: pr.schema?.type ?? pr.type, + example: pr.schema?.example ?? pr.example ?? pr.default })), responses: Object.keys(op.responses ?? {}).map((code) => ({ status: code, diff --git a/src/ai/testDesigner.ts b/src/ai/testDesigner.ts index d616900..5cd8929 100644 --- a/src/ai/testDesigner.ts +++ b/src/ai/testDesigner.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { getClient, MODEL } from './client'; -import { DESIGNER_SYSTEM } from './prompts'; +import { DESIGNER_SYSTEM, SITE_CONTEXT_INSTRUCTION } from './prompts'; import { redact } from './redact'; import { registerAllSensitive } from '../fixtures/data'; @@ -97,14 +97,31 @@ const DESIGN_SCHEMA = { additionalProperties: false } as const; -export async function designTests(userStory: string, maxScenarios?: number): Promise { +export async function designTests( + userStory: string, + maxScenarios?: number, + siteContext?: string +): Promise { const client = getClient(); // Before going to the LLM: load known secret values + redact the story. registerAllSensitive(); - const safeStory = redact(userStory); + const safeStory = redact(userStory).trim(); + const hasContext = !!siteContext?.trim(); + if (!safeStory && !hasContext) { + throw new Error('Provide a user story or a system context (site/API) to design tests from.'); + } - let content = `Produce a test design for this user story:\n\n${safeStory}`; + // With no explicit story, the inspected/probed system IS the requirement — derive the scenarios + // from its real capabilities instead of a narrative. + let content = safeStory + ? `Produce a test design for this user story:\n\n${safeStory}` + : `Produce a test design for the system under test. No explicit user story was given — derive the ` + + `scenarios from the LIVE CONTEXT below: cover each capability's happy path, key negatives, and ` + + `edge/boundary cases, and for APIs also the status codes, contract/response shape, and input validation.`; + if (hasContext) { + content += `\n\n${SITE_CONTEXT_INSTRUCTION}\n\nLIVE CONTEXT (real UI elements and/or API endpoints):\n\n${redact(siteContext!)}`; + } if (maxScenarios && maxScenarios > 0) { // Quick-trial mode: keep the design (and therefore generation) small and fast. content += diff --git a/src/web/server.ts b/src/web/server.ts index 2dabf40..858a884 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1,31 +1,39 @@ import * as path from 'path'; import * as fs from 'fs'; +import * as os from 'os'; import * as crypto from 'crypto'; import express, { Request, Response } from 'express'; import { designTests, renderDesignMarkdown, TestDesign } from '../ai/testDesigner'; import { generateTests, writeArtifacts, correctArtifacts, GeneratedArtifacts } from '../ai/testGenerator'; +import { inspectPage, SelectorMap } from '../ai/pageInspector'; +import { probeApi, EndpointMap } from '../ai/specProbe'; import { verifyTypeScript } from '../ai/verifier'; import { runAgent } from '../agent/orchestrator'; import { AgentIO, AgentEvent } from '../agent/io'; const app = express(); app.use(express.json({ limit: '1mb' })); -app.use(express.static(path.join(process.cwd(), 'public'))); +// Scenario Studio is the home page ('/'); the older code studio (index.html) is retired. +app.use(express.static(path.join(process.cwd(), 'public'), { index: 'scenarios.html' })); const tsScope = (written: string[]) => written.map((w) => w.split(' ')[0]).filter((f) => f.endsWith('.ts')); -/** Reads the project source files the self-correction step may need to update. */ -function projectSources(): { fileName: string; content: string }[] { +/** Reads the project source files the self-correction step may need to update (mode-aware). */ +function projectSources(mode: 'ui' | 'api' = 'ui'): { fileName: string; content: string }[] { + const dirs = mode === 'api' ? ['src/api', 'src/steps/api'] : ['src/pages', 'src/fixtures']; const out: { fileName: string; content: string }[] = []; - for (const dir of ['src/pages', 'src/fixtures']) { - const abs = path.join(process.cwd(), dir); - if (!fs.existsSync(abs)) continue; - for (const f of fs.readdirSync(abs)) { - if (f.endsWith('.ts') && !f.endsWith('.generated')) { - out.push({ fileName: f, content: fs.readFileSync(path.join(abs, f), 'utf-8') }); + const walk = (rel: string) => { + const abs = path.join(process.cwd(), rel); + if (!fs.existsSync(abs)) return; + for (const entry of fs.readdirSync(abs, { withFileTypes: true })) { + const childRel = path.join(rel, entry.name); + if (entry.isDirectory()) walk(childRel); + else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.generated') && !entry.name.endsWith('.bak')) { + out.push({ fileName: childRel, content: fs.readFileSync(path.join(process.cwd(), childRel), 'utf-8') }); } } - } + }; + for (const d of dirs) walk(d); return out; } @@ -57,6 +65,99 @@ const handler = } }; +/** Compact, model-friendly summary of an inspected page — UI grounding for the design. */ +function siteContextFrom(map: SelectorMap): string { + const named = map.entries + .filter((e) => e.name && !e.unresolved) + .slice(0, 40) + .map((e) => `- ${e.role ?? e.tag}${e.name ? ` "${e.name}"` : ''}`); + return [ + `UI page title: ${map.title}`, + `URL: ${map.url}`, + named.length ? `Notable elements on the page:\n${named.join('\n')}` : 'No notable named elements found.' + ].join('\n'); +} + +/** Compact summary of a probed OpenAPI spec — API grounding for the design. */ +function apiContextFrom(map: EndpointMap): string { + const lines = map.endpoints.map( + (e) => + `- ${e.method} ${e.path}${e.summary ? ` — ${e.summary}` : ''}` + + (e.responses.length ? ` [statuses: ${e.responses.map((r) => r.status).join(', ')}]` : '') + ); + return [ + `API: ${map.title}${map.version ? ` v${map.version}` : ''}`, + `Base URL: ${map.baseUrl}`, + lines.length ? `Endpoints:\n${lines.join('\n')}` : 'No endpoints found in the spec.' + ].join('\n'); +} + +/** Probes an OpenAPI spec given as a URL (fetched) or pasted JSON. Spec-only (no live calls). */ +async function probeSpecInput(input: string): Promise { + let json: string; + if (/^https?:\/\//i.test(input)) { + const r = await fetch(input); + if (!r.ok) throw new Error(`Could not fetch the spec (HTTP ${r.status}) from ${input}`); + json = await r.text(); + } else { + json = input; + } + try { + JSON.parse(json); + } catch { + throw new Error('The OpenAPI spec must be valid JSON (paste JSON, or a URL that returns JSON).'); + } + const tmp = path.join(os.tmpdir(), `aiwright-spec-${crypto.randomUUID()}.json`); + fs.writeFileSync(tmp, json); + try { + return await probeApi(tmp, { live: false }); + } finally { + fs.rmSync(tmp, { force: true }); + } +} + +// PRODUCT flow: { stories, siteUrl?, apiSpec? } -> scenarios. Grounds the test design in the real +// system — a live UI inspect (siteUrl), an OpenAPI probe (apiSpec = URL or pasted JSON), or both — +// and returns the scenarios (no code written). "Enter your site and/or API, share your stories, +// get the scenarios." +app.post( + '/api/scenarios', + handler(async (req, res) => { + const stories = String(req.body?.stories ?? '').trim(); + const siteUrl = String(req.body?.siteUrl ?? '').trim(); + const apiSpec = String(req.body?.apiSpec ?? '').trim(); + // Stories are optional: a site or an API spec is enough to design from (the system is the + // requirement). At least one of the three must be present. + if (!stories && !siteUrl && !apiSpec) { + res.status(400).json({ error: 'Provide a site URL, an API spec, or your stories.' }); + return; + } + + const contexts: string[] = []; + let site: unknown; + let api: unknown; + + if (siteUrl) { + if (!/^https?:\/\//i.test(siteUrl)) { + res.status(400).json({ error: 'Site URL must start with http:// or https://' }); + return; + } + const map = await inspectPage(siteUrl); + contexts.push(siteContextFrom(map)); + site = { title: map.title, url: map.url, elements: map.entries.length, warnings: map.warnings }; + } + + if (apiSpec) { + const map = await probeSpecInput(apiSpec); + contexts.push(apiContextFrom(map)); + api = { title: map.title, version: map.version, baseUrl: map.baseUrl, endpoints: map.endpoints.length }; + } + + const design = await designTests(stories, undefined, contexts.join('\n\n---\n\n')); + res.json({ design, site, api }); + }) +); + // Story -> structured test design (the "what to test" layer). app.post( '/api/design', @@ -94,6 +195,60 @@ app.post( }) ); +// PRODUCT flow step 2: selected scenarios -> a runnable suite written into the project. +// Re-grounds in the same system (API spec -> endpoint map + API mode, else site -> selector map + +// UI mode), generates the feature + steps + clients/page objects for ONLY the chosen scenarios, +// writes them, type-checks, and self-corrects up to 2 rounds so the result compiles. +app.post( + '/api/generate-suite', + handler(async (req, res) => { + const stories = String(req.body?.stories ?? '').trim(); + const siteUrl = String(req.body?.siteUrl ?? '').trim(); + const apiSpec = String(req.body?.apiSpec ?? '').trim(); + const design = req.body?.design as TestDesign | undefined; + const selected = (req.body?.selected as number[] | undefined) ?? []; + if (!design) { + res.status(400).json({ error: 'Provide the design.' }); + return; + } + const scenarios = design.scenarios.filter((_, i) => selected.includes(i)); + if (scenarios.length === 0) { + res.status(400).json({ error: 'Select at least one scenario.' }); + return; + } + const approved: TestDesign = { ...design, scenarios }; + + // Mode + grounding follow the source: an API spec -> API lane; otherwise the site -> UI lane. + const mode: 'ui' | 'api' = apiSpec ? 'api' : 'ui'; + let mapJson: string | undefined; + if (apiSpec) { + mapJson = JSON.stringify(await probeSpecInput(apiSpec), null, 2); + } else if (siteUrl) { + mapJson = JSON.stringify(await inspectPage(siteUrl), null, 2); + } + + const storyText = stories || approved.understanding || approved.title; + let artifacts = await generateTests(storyText, renderDesignMarkdown(approved), mapJson, undefined, mode); + let written = writeArtifacts(artifacts, process.cwd(), { overwrite: true }, mode); + let verify = verifyTypeScript(tsScope(written)); + let rounds = 0; + while (!verify.ok && rounds < 2) { + rounds++; + artifacts = await correctArtifacts(storyText, artifacts, verify.errors, projectSources(mode), mapJson, mode); + written = writeArtifacts(artifacts, process.cwd(), { overwrite: true }, mode); + verify = verifyTypeScript(tsScope(written)); + } + + res.json({ + mode, + written, + rounds, + notes: artifacts.notes, + verify: { ok: verify.ok, errors: verify.errors } + }); + }) +); + // Write previewed artifacts into the project (never overwrites existing files), then // type-check them so the UI shows whether the generated code compiles. app.post( @@ -257,7 +412,7 @@ const PORT = Number(process.env.PORT ?? 5173); // Bind to loopback only so the endpoints (which hold the API key and write files) // are never exposed on the network. app.listen(PORT, '127.0.0.1', () => { - console.log(`aiwright web UI running at http://localhost:${PORT}`); - console.log(` QA agent (live run + approval): http://localhost:${PORT}/agent.html`); + console.log(`NeuralQA Scenario Studio running at http://localhost:${PORT}`); + console.log(` Agent (live run + approval): http://localhost:${PORT}/agent.html`); if (TOKEN) console.log('Token auth enabled (AIWRIGHT_TOKEN set).'); });