Add AI-assisted QA test workflow: scenario cards, runner, hooks, API capture
A complete loop for defining, generating, running and gating tests: - qa/: scenario-card template + examples (API/UI/binding), the Claude generation prompt, and a QA runbook tying it together - e2e/CLAUDE.md: conventions that keep AI-generated tests consistent - run-tests.ps1: one runner for every layer (backend/api/web/ui/binding), logs per run under .testlogs/, JUnit output, evidence report, and a re-seed guard against stale demo data - .githooks/ + scripts/install-hooks.ps1: pre-commit secret scan and pre-push unit tests (shift-left before CI) - evidence report now captures each API call's request, payload and response (Authorization redacted); JUnit reporter added for CI - scripts/db.mjs: db:check freshness guard Azure DevOps pipeline deferred; the runner already emits JUnit + the evidence report for any CI to publish. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
df9e534e42
commit
45b5b34680
@@ -21,6 +21,7 @@ import type { FullConfig, FullResult, Reporter, Suite, TestCase, TestResult } fr
|
||||
import { DATA_PREFIX, SHOT_PREFIX } from '../fixtures/evidence';
|
||||
import { renderHtml } from './render-html';
|
||||
import {
|
||||
ApiCall,
|
||||
RunMeta,
|
||||
Scenario,
|
||||
Step,
|
||||
@@ -69,6 +70,7 @@ export default class EvidenceReporter implements Reporter {
|
||||
const suite = path.slice(2, -1).join(' › ');
|
||||
|
||||
const steps = this.collectSteps(test, result);
|
||||
const apiCalls = this.collectApiCalls(result);
|
||||
|
||||
this.scenarios.push({
|
||||
project,
|
||||
@@ -79,10 +81,28 @@ export default class EvidenceReporter implements Reporter {
|
||||
expected: result.status === test.expectedStatus,
|
||||
durationMs: result.duration,
|
||||
steps,
|
||||
apiCalls,
|
||||
error: result.error?.message?.split('\n').slice(0, 4).join('\n'),
|
||||
});
|
||||
}
|
||||
|
||||
/** Reads the evidence-http attachment the apiTraffic fixture produced. */
|
||||
private collectApiCalls(result: TestResult): ApiCall[] {
|
||||
const att = result.attachments.find((a) => a.name === 'evidence-http');
|
||||
if (!att) {
|
||||
return [];
|
||||
}
|
||||
const raw = att.body ? att.body.toString('utf8') : att.path ? safeRead(att.path) : '';
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as ApiCall[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Pairs `evidence:NN:name` screenshots with `evidence-data:NN:name` blobs. */
|
||||
private collectSteps(test: TestCase, result: TestResult): Step[] {
|
||||
const bySeq = new Map<string, Step>();
|
||||
@@ -180,6 +200,7 @@ export default class EvidenceReporter implements Reporter {
|
||||
(n, s) => n + s.steps.filter((step) => step.shot).length,
|
||||
0,
|
||||
),
|
||||
apiCalls: this.scenarios.reduce((n, s) => n + s.apiCalls.length, 0),
|
||||
};
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
@@ -206,7 +227,7 @@ export default class EvidenceReporter implements Reporter {
|
||||
'Evidence report — what passed, with data and screenshots:',
|
||||
` ${url}`,
|
||||
` ${summary.passed}/${summary.total} passed · ${summary.screenshots} screenshots · ` +
|
||||
`${summary.narrated} scenarios with recorded data`,
|
||||
`${summary.apiCalls} API calls · ${summary.narrated} scenarios with recorded data`,
|
||||
'',
|
||||
' Open it: npm run report',
|
||||
' Markdown: ' + pathToFileURL(join(OUT_DIR, 'report.md')).href,
|
||||
@@ -254,7 +275,7 @@ function renderMarkdown(summary: Summary, scenarios: Scenario[]): string {
|
||||
lines.push(`### ${mark} ${heading}`, '');
|
||||
lines.push(`*${scenario.suite ? scenario.suite + ' · ' : ''}${scenario.project} · ${friendly(scenario.durationMs)}*`, '');
|
||||
|
||||
if (scenario.steps.length === 0) {
|
||||
if (scenario.steps.length === 0 && scenario.apiCalls.length === 0) {
|
||||
lines.push('_No evidence captured._', '');
|
||||
continue;
|
||||
}
|
||||
@@ -272,10 +293,32 @@ function renderMarkdown(summary: Summary, scenarios: Scenario[]): string {
|
||||
lines.push(``, '');
|
||||
}
|
||||
}
|
||||
|
||||
if (scenario.apiCalls.length > 0) {
|
||||
lines.push(`**API calls (${scenario.apiCalls.length})**`, '');
|
||||
for (const call of scenario.apiCalls) {
|
||||
lines.push(`- \`${call.method} ${call.path}\` → **${call.status}** (${call.durationMs} ms)`);
|
||||
if (call.requestBody) {
|
||||
lines.push('', ' Request:', '', ' ```json', indent(call.requestBody), ' ```');
|
||||
}
|
||||
if (call.responseBody) {
|
||||
lines.push('', ' Response:', '', ' ```json', indent(call.responseBody), ' ```');
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** Indents a block two spaces so it nests under a Markdown list item. */
|
||||
function indent(text: string): string {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => ' ' + line)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* ("2.3.1 failed"), and a print stylesheet so it can be saved as a PDF.
|
||||
*/
|
||||
import {
|
||||
ApiCall,
|
||||
Area,
|
||||
RunMeta,
|
||||
Scenario,
|
||||
@@ -108,6 +109,7 @@ ${STYLES}
|
||||
<div><dt>Scenarios executed</dt><dd>${summary.total}</dd></div>
|
||||
<div><dt>Duration</dt><dd>${friendly(summary.durationMs)}</dd></div>
|
||||
<div><dt>Screenshots captured</dt><dd>${summary.screenshots}</dd></div>
|
||||
<div><dt>API calls captured</dt><dd>${summary.apiCalls}</dd></div>
|
||||
<div><dt>Scenarios with recorded data</dt><dd>${summary.narrated}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
@@ -207,10 +209,11 @@ function renderArea(entry: AreaBlock): string {
|
||||
|
||||
function renderScenario(scenario: Scenario, ref: string): string {
|
||||
const hasFacts = scenario.steps.some((s) => s.facts);
|
||||
const steps =
|
||||
scenario.steps.length === 0
|
||||
? '<p class="no-evidence">No screenshot was captured for this scenario.</p>'
|
||||
: scenario.steps.map(renderStep).join('');
|
||||
const hasAnything = scenario.steps.length > 0 || scenario.apiCalls.length > 0;
|
||||
const steps = scenario.steps.map(renderStep).join('');
|
||||
const emptyNote = hasAnything
|
||||
? ''
|
||||
: '<p class="no-evidence">No evidence was captured for this scenario.</p>';
|
||||
|
||||
return `
|
||||
<details class="scenario" data-facts="${hasFacts ? '1' : '0'}"${hasFacts ? ' open' : ''}>
|
||||
@@ -222,6 +225,7 @@ function renderScenario(scenario: Scenario, ref: string): string {
|
||||
</span>
|
||||
<span class="right">
|
||||
<span class="hide-narrow">${escapeHtml(scenario.project)}</span>
|
||||
<span class="hide-narrow">${scenario.apiCalls.length} API</span>
|
||||
<span>${friendly(scenario.durationMs)}</span>
|
||||
<span class="tag ${statusClass(scenario.status)}">${statusWord(scenario.status)}</span>
|
||||
</span>
|
||||
@@ -229,10 +233,56 @@ function renderScenario(scenario: Scenario, ref: string): string {
|
||||
<div class="body">
|
||||
${scenario.error ? `<pre class="error">${escapeHtml(scenario.error)}</pre>` : ''}
|
||||
${steps}
|
||||
${emptyNote}
|
||||
${renderApiCalls(scenario.apiCalls)}
|
||||
</div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
/** The API traffic block: collapsed by default so it never dominates. */
|
||||
function renderApiCalls(calls: ApiCall[]): string {
|
||||
if (calls.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const rows = calls.map(renderApiCall).join('');
|
||||
|
||||
return `
|
||||
<details class="api">
|
||||
<summary>API calls · ${calls.length} request${calls.length === 1 ? '' : 's'}</summary>
|
||||
<div class="api-list">${rows}</div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function renderApiCall(call: ApiCall): string {
|
||||
const cls = call.status >= 500 ? 'c5' : call.status >= 400 ? 'c4' : call.status >= 300 ? 'c3' : 'c2';
|
||||
|
||||
const headerRows = Object.entries(call.requestHeaders)
|
||||
.map(([k, v]) => `<tr><td>${escapeHtml(k)}</td><td>${escapeHtml(v)}</td></tr>`)
|
||||
.join('');
|
||||
|
||||
const request = call.requestBody
|
||||
? `<div class="part"><span class="part-label">Request payload</span><pre>${escapeHtml(call.requestBody)}</pre></div>`
|
||||
: '';
|
||||
|
||||
const response = call.responseBody
|
||||
? `<div class="part"><span class="part-label">Response body${call.truncated ? ' (truncated)' : ''}</span><pre>${escapeHtml(call.responseBody)}</pre></div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="call">
|
||||
<div class="call-head">
|
||||
<span class="method m-${escapeHtml(call.method.toLowerCase())}">${escapeHtml(call.method)}</span>
|
||||
<span class="call-path">${escapeHtml(call.path)}</span>
|
||||
<span class="status ${cls}">${call.status} ${escapeHtml(call.statusText)}</span>
|
||||
<span class="call-time">${call.durationMs} ms</span>
|
||||
</div>
|
||||
<details class="headers"><summary>Request headers</summary><table>${headerRows}</table></details>
|
||||
${request}
|
||||
${response}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderStep(step: Step): string {
|
||||
const facts = step.facts
|
||||
? `<table class="facts">${Object.entries(step.facts)
|
||||
@@ -372,6 +422,41 @@ const STYLES = `
|
||||
border-radius:6px;font-size:.83rem;white-space:pre-wrap;
|
||||
font-family:ui-monospace,monospace}
|
||||
|
||||
/* --- API traffic --- */
|
||||
details.api{border-top:1px solid var(--line);margin-top:6px}
|
||||
details.api > summary{list-style:none;cursor:pointer;padding:12px 0 10px;font-size:.78rem;
|
||||
font-weight:650;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)}
|
||||
details.api > summary::-webkit-details-marker{display:none}
|
||||
details.api > summary::before{content:'\\25B8 ';color:var(--faint)}
|
||||
details.api[open] > summary::before{content:'\\25BE '}
|
||||
.api-list{display:flex;flex-direction:column;gap:10px;padding-bottom:6px}
|
||||
.call{border:1px solid var(--line);border-radius:7px;overflow:hidden;background:#fff}
|
||||
.call-head{display:flex;align-items:center;gap:10px;padding:8px 11px;background:#f8fafc;
|
||||
flex-wrap:wrap;font-size:.82rem}
|
||||
.method{font-weight:700;font-size:.7rem;letter-spacing:.04em;padding:2px 7px;border-radius:4px;
|
||||
background:#e5e7eb;color:#374151}
|
||||
.method.m-get{background:#e0edff;color:#0b5cd6}
|
||||
.method.m-post{background:#e6f4f1;color:#067a6e}
|
||||
.method.m-patch,.method.m-put{background:#fef3c7;color:#b45309}
|
||||
.method.m-delete{background:#fdeaee;color:#b3243f}
|
||||
.call-path{font-family:ui-monospace,Menlo,monospace;font-size:.8rem;color:var(--ink);
|
||||
word-break:break-all;flex:1;min-width:120px}
|
||||
.status{font-weight:700;font-size:.76rem;font-variant-numeric:tabular-nums}
|
||||
.status.c2{color:var(--ok)} .status.c3{color:var(--med)}
|
||||
.status.c4{color:var(--fail)} .status.c5{color:#fff;background:var(--fail);padding:1px 6px;border-radius:4px}
|
||||
.call-time{color:var(--faint);font-size:.76rem;font-variant-numeric:tabular-nums}
|
||||
.part{padding:8px 11px;border-top:1px solid var(--line)}
|
||||
.part-label{display:block;font-size:.68rem;letter-spacing:.06em;text-transform:uppercase;
|
||||
color:var(--faint);font-weight:650;margin-bottom:5px}
|
||||
.call pre{background:#0f172a;color:#e2e8f0;padding:10px 12px;border-radius:6px;overflow-x:auto;
|
||||
font:12px/1.5 ui-monospace,Menlo,monospace;margin:0;max-height:340px}
|
||||
details.headers{padding:8px 11px;border-top:1px solid var(--line)}
|
||||
details.headers > summary{cursor:pointer;font-size:.72rem;color:var(--muted)}
|
||||
details.headers table{border-collapse:collapse;font-size:.76rem;margin-top:6px}
|
||||
details.headers td{border:1px solid var(--line);padding:3px 8px;
|
||||
font-family:ui-monospace,Menlo,monospace;word-break:break-all}
|
||||
details.headers td:first-child{color:var(--muted);background:#f8fafc}
|
||||
|
||||
.toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:20px}
|
||||
.toolbar button{border:1px solid var(--rule);background:#fff;border-radius:6px;
|
||||
padding:6px 13px;font:inherit;font-size:.85rem;cursor:pointer;color:var(--body)}
|
||||
@@ -468,7 +553,9 @@ const SCRIPT = `
|
||||
// first and put it back the way the reader had it afterwards.
|
||||
let restore = [];
|
||||
window.addEventListener('beforeprint', () => {
|
||||
restore = [...document.querySelectorAll('.scenario')].map((d) => [d, d.open]);
|
||||
// Open every details element (scenarios and the API/header blocks inside),
|
||||
// since a collapsed one prints only its summary line.
|
||||
restore = [...document.querySelectorAll('details')].map((d) => [d, d.open]);
|
||||
for (const [d] of restore) {
|
||||
d.open = true;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,19 @@ export interface Step {
|
||||
facts?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** One API request/response pair observed during a scenario. */
|
||||
export interface ApiCall {
|
||||
method: string;
|
||||
path: string;
|
||||
status: number;
|
||||
statusText: string;
|
||||
durationMs: number;
|
||||
requestHeaders: Record<string, string>;
|
||||
requestBody?: string;
|
||||
responseBody?: string;
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
/** One test, as the report talks about it. */
|
||||
export interface Scenario {
|
||||
project: string;
|
||||
@@ -25,6 +38,7 @@ export interface Scenario {
|
||||
expected: boolean;
|
||||
durationMs: number;
|
||||
steps: Step[];
|
||||
apiCalls: ApiCall[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -45,6 +59,7 @@ export interface Summary {
|
||||
skipped: number;
|
||||
narrated: number;
|
||||
screenshots: number;
|
||||
apiCalls: number;
|
||||
}
|
||||
|
||||
// --- areas -----------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user