1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 17:29:58 +01:00
This commit is contained in:
Janosch 2026-03-27 17:52:56 +01:00
parent 061c2b4f8d
commit 5bc316ff31
6 changed files with 289 additions and 11 deletions

View file

@ -12,6 +12,7 @@
"devDependencies": {
"@playwright/test": "^1.51.0",
"@types/node": "^20.0.0",
"tsx": "^4.19.0",
"typescript": "^5.4.0"
}
}

View file

@ -0,0 +1,220 @@
#!/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);
});