Files
TS_Proto/e2e/fixtures/http-capture.ts
T
nilesh.pandClaude Opus 4.8 45b5b34680 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>
2026-10-10 13:04:46 +05:30

111 lines
3.5 KiB
TypeScript

/**
* Captures the API traffic a test produced, for the evidence report.
*
* A screenshot shows what the user saw; this shows what crossed the wire -- the
* request (method, path, payload) and the response (status, body) for every
* `/api/**` call. Together they make the report a complete record of the
* scenario.
*
* Security: the Authorization header carries the Basic-auth credential (a
* base64 of email:password), so it is redacted before anything is stored. The
* report is meant to be shared; a credential must never ride along in it.
*/
import type { Response } from '@playwright/test';
export interface ApiCall {
method: string;
path: string;
status: number;
statusText: string;
durationMs: number;
requestHeaders: Record<string, string>;
requestBody?: string;
responseBody?: string;
truncated?: boolean;
}
// Headers that must never appear in a shareable report.
const SENSITIVE = new Set(['authorization', 'cookie', 'set-cookie', 'proxy-authorization']);
// Response bodies are capped so a large flight list cannot bloat the report.
const MAX_BODY = 12000;
function redactHeaders(headers: Record<string, string>): Record<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
out[key] = SENSITIVE.has(key.toLowerCase()) ? '[redacted]' : value;
}
return out;
}
/** Pretty-prints JSON and caps length, reporting whether it was truncated. */
function presentBody(raw: string, contentType: string): { body: string; truncated: boolean } {
let text = raw;
if (/json/i.test(contentType)) {
try {
text = JSON.stringify(JSON.parse(raw), null, 2);
} catch {
// Leave non-parseable bodies as-is.
}
}
if (text.length > MAX_BODY) {
return { body: text.slice(0, MAX_BODY) + '\n… (truncated)', truncated: true };
}
return { body: text, truncated: false };
}
/**
* Turns one response into an ApiCall record, or null if it is not an API call.
* The body is read here, while the response is still live, and returned via the
* promise the caller awaits at test teardown.
*/
export function captureApiCall(response: Response): Promise<ApiCall | null> {
const request = response.request();
let url: URL;
try {
url = new URL(request.url());
} catch {
return Promise.resolve(null);
}
if (!url.pathname.startsWith('/api/')) {
return Promise.resolve(null);
}
return (async () => {
try {
const contentType = response.headers()['content-type'] ?? '';
let responseBody: string | undefined;
let truncated = false;
if (/json|text|xml|form/i.test(contentType)) {
const raw = await response.text().catch(() => '');
if (raw) {
const shown = presentBody(raw, contentType);
responseBody = shown.body;
truncated = shown.truncated;
}
}
const timing = request.timing();
const durationMs = timing.responseEnd > 0 ? Math.round(timing.responseEnd) : 0;
return {
method: request.method(),
path: url.pathname + url.search,
status: response.status(),
statusText: response.statusText(),
durationMs,
requestHeaders: redactHeaders(request.headers()),
requestBody: request.postData() ?? undefined,
responseBody,
truncated: truncated || undefined,
};
} catch {
// A response that cannot be read (e.g. the page navigated away) is
// skipped rather than failing the test it was only observing.
return null;
}
})();
}