mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 08:29:36 +01:00
220 lines
9.4 KiB
JavaScript
220 lines
9.4 KiB
JavaScript
#!/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<string, { dir: string; file: string; apiBase: string; label: string }> = {
|
||
'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<boolean> {
|
||
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<void> {
|
||
const issueNumber = process.argv[2];
|
||
if (!issueNumber || !/^\d+$/.test(issueNumber)) {
|
||
console.error('Aufruf: npx tsx scripts/generate-tests-from-issue.ts <issue-nummer>');
|
||
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);
|
||
});
|