#!/usr/bin/env node /** * generate-tests-from-issue.ts * * Generiert einen Playwright-Spec-Stub aus einem GitHub Issue. * * Aufruf (via justfile): * just generate-test 65 * * Direkt: * npx tsx scripts/generate-tests-from-issue.ts 65 * * Voraussetzung: `gh` CLI muss authentifiziert sein. * nix shell nixpkgs#gh -c gh auth status */ import { execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as readline from 'readline'; // ── Konfiguration ────────────────────────────────────────────────────────────── /** Repo-Pfad relativ zum Repo-Root */ const TESTS_DIR = path.join(__dirname, '..', 'tests', 'api'); /** Mapping: TC-Präfix → { Verzeichnis, Dateiname, API-Pfad, Beschreibung } */ const TC_MAP: Record = { 'TC-CAT': { dir: 'masterdata', file: 'categories.spec.ts', apiBase: '/api/categories', label: 'Produktkategorien' }, 'TC-SUP': { dir: 'masterdata', file: 'suppliers.spec.ts', apiBase: '/api/suppliers', label: 'Lieferanten' }, 'TC-ART': { dir: 'masterdata', file: 'articles.spec.ts', apiBase: '/api/articles', label: 'Artikel' }, 'TC-CUS': { dir: 'masterdata', file: 'customers.spec.ts', apiBase: '/api/customers', label: 'Kunden' }, 'TC-B2B': { dir: 'masterdata', file: 'contracts.spec.ts', apiBase: '/api/customers/{id}/frame-contract', label: 'Rahmenverträge' }, 'TC-AUTH': { dir: 'auth', file: 'authorization.spec.ts', apiBase: '/api/auth', label: 'Autorisierung' }, 'TC-REC': { dir: 'production', file: 'recipes.spec.ts', apiBase: '/api/recipes', label: 'Rezepte' }, 'TC-BAT': { dir: 'production', file: 'batches.spec.ts', apiBase: '/api/production/batches', label: 'Produktionschargen' }, 'TC-ORD': { dir: 'production', file: 'orders.spec.ts', apiBase: '/api/production/production-orders', label: 'Produktionsaufträge' }, 'TC-TRACE': { dir: 'production', file: 'traceability.spec.ts', apiBase: '/api/production/batches/{id}/trace-*', label: 'Rückverfolgung' }, 'TC-STOCK': { dir: 'inventory', file: 'stock.spec.ts', apiBase: '/api/inventory/stocks', label: 'Lagerbestand' }, 'TC-MOV': { dir: 'inventory', file: 'movements.spec.ts', apiBase: '/api/inventory/stock-movements', label: 'Warenbewegungen' }, 'TC-RES': { dir: 'inventory', file: 'reservations.spec.ts', apiBase: '/api/inventory/stocks/{id}/reservations', label: 'Reservierungen' }, 'TC-INV': { dir: 'inventory', file: 'inventory-counts.spec.ts', apiBase: '/api/inventory/inventory-counts', label: 'Inventurzählungen' }, }; // ── GitHub Issue laden ───────────────────────────────────────────────────────── interface GitHubIssue { number: number; title: string; body: string; labels: { name: string }[]; } function fetchIssue(issueNumber: string): GitHubIssue { try { const raw = execSync( `nix shell nixpkgs#gh -c gh api repos/{owner}/{repo}/issues/${issueNumber}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, ); return JSON.parse(raw); } catch { // Fallback ohne nix try { const raw = execSync( `gh api repos/{owner}/{repo}/issues/${issueNumber}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, ); return JSON.parse(raw); } catch (err) { throw new Error( `GitHub Issue #${issueNumber} konnte nicht geladen werden.\n` + `Stelle sicher, dass 'gh' authentifiziert ist: nix shell nixpkgs#gh -c gh auth status\n` + String(err), ); } } } // ── Test-Cases aus Issue-Body extrahieren ────────────────────────────────────── interface TestCase { id: string; // z.B. "TC-SUP-01" title: string; // z.B. "Lieferant erstellen – Pflichtfelder" } function extractTestCases(body: string): TestCase[] { const cases: TestCase[] = []; // Erkennt: "TC-SUP-01: Beschreibung" oder "**TC-SUP-01**" oder "| TC-SUP-01 |" const pattern = /\b(TC-[A-Z]+-\d{2,3})[:\s\|*]+([^\n|*]{3,80})/g; let match: RegExpExecArray | null; while ((match = pattern.exec(body)) !== null) { const id = match[1].trim(); const title = match[2].trim().replace(/^\*+|\*+$/g, '').trim(); if (!cases.some(c => c.id === id)) { cases.push({ id, title }); } } return cases; } /** Ermittelt den TC-Präfix aus einer Liste von TestCase-IDs */ function detectTcPrefix(cases: TestCase[]): string | null { for (const tc of cases) { // TC-TRACE vor TC-T* prüfen (länger zuerst) for (const prefix of Object.keys(TC_MAP).sort((a, b) => b.length - a.length)) { if (tc.id.startsWith(prefix)) return prefix; } } return null; } // ── Spec-Datei generieren ────────────────────────────────────────────────────── function generateSpec(issue: GitHubIssue, cases: TestCase[], prefix: string): string { const meta = TC_MAP[prefix]; const describeName = `${prefix}: ${meta.label}`; const testBlocks = cases.length > 0 ? cases.map(tc => ` test('${tc.id}: ${tc.title}', async ({ request, adminToken }) => { // TODO: Implementierung // API: ${meta.apiBase} const res = await request.get('${meta.apiBase.replace(/\{[^}]+\}/g, 'REPLACE_ID')}', { headers: { Authorization: \`Bearer \${adminToken}\` }, }); expect(res.status()).toBe(200); });`).join('\n') : ` test.todo('TCs aus Issue #${issue.number} noch nicht implementiert');`; return `import { test, expect } from '../../../fixtures/auth.fixture.js'; /** * ${describeName} * Quelle: GitHub Issue #${issue.number} * Titel: ${issue.title} * * ACHTUNG: Automatisch generierter Stub – Implementierung erforderlich! */ test.describe('${describeName}', () => {${testBlocks} }); `; } // ── Ausgabe-Pfad bestimmen ───────────────────────────────────────────────────── function resolveOutputPath(prefix: string, issueNumber: string): { full: string; relative: string } | null { const meta = TC_MAP[prefix]; if (!meta) return null; const dir = path.join(TESTS_DIR, meta.dir); const full = path.join(dir, meta.file); const relative = path.join('tests', 'api', meta.dir, meta.file); return { full, relative }; } // ── Interaktive Abfrage ──────────────────────────────────────────────────────── async function confirm(question: string): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise(resolve => { rl.question(question, answer => { rl.close(); resolve(answer.trim().toLowerCase().startsWith('j') || answer.trim().toLowerCase() === 'y'); }); }); } // ── Main ─────────────────────────────────────────────────────────────────────── async function main(): Promise { const issueNumber = process.argv[2]; if (!issueNumber || !/^\d+$/.test(issueNumber)) { console.error('Aufruf: npx tsx scripts/generate-tests-from-issue.ts '); process.exit(1); } console.log(`\nLade GitHub Issue #${issueNumber}...`); const issue = fetchIssue(issueNumber); console.log(`Titel: ${issue.title}`); const cases = extractTestCases(issue.body ?? ''); if (cases.length > 0) { console.log(`\nGefundene Test-Cases (${cases.length}):`); cases.forEach(tc => console.log(` ${tc.id}: ${tc.title}`)); } else { console.log('\nKeine TC-XXX-YY Muster im Issue gefunden – Stub ohne Test-Cases wird generiert.'); } const prefix = detectTcPrefix(cases); if (!prefix) { console.error('\nTC-Präfix konnte nicht erkannt werden. Unterstützte Präfixe: ' + Object.keys(TC_MAP).join(', ')); process.exit(1); } console.log(`\nErkannter Präfix: ${prefix} → ${TC_MAP[prefix].dir}/${TC_MAP[prefix].file}`); const out = resolveOutputPath(prefix, issueNumber); if (!out) { console.error('Ausgabepfad konnte nicht ermittelt werden.'); process.exit(1); } if (fs.existsSync(out.full)) { const overwrite = await confirm(`\nDatei ${out.relative} existiert bereits. Überschreiben? [j/N] `); if (!overwrite) { console.log('Abgebrochen.'); process.exit(0); } } const content = generateSpec(issue, cases, prefix); fs.mkdirSync(path.dirname(out.full), { recursive: true }); fs.writeFileSync(out.full, content, 'utf-8'); console.log(`\nSpec erstellt: ${out.relative}`); console.log('Nächster Schritt: TODOs im generierten Test implementieren.'); } main().catch(err => { console.error(err instanceof Error ? err.message : err); process.exit(1); });