1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 06:29:35 +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

50
.github/workflows/e2e.yml vendored Normal file
View file

@ -0,0 +1,50 @@
name: E2E Tests
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
jobs:
e2e:
name: Playwright API E2E
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Run E2E Tests
run: |
docker compose -f test-automation/docker-compose.e2e.yml up \
--build \
--abort-on-container-exit \
--exit-code-from e2e-runner
- name: Upload JUnit Report
uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-junit-report
path: test-automation/e2e-results/junit.xml
retention-days: 30
- name: Upload Playwright HTML Report
uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-html-report
path: test-automation/e2e-results/
retention-days: 7
- name: Cleanup
if: always()
run: docker compose -f test-automation/docker-compose.e2e.yml down --volumes --remove-orphans

View file

@ -1,5 +1,7 @@
# Effigenix Fleischerei ERP
[![E2E Tests](https://github.com/s-frick/effigenix/actions/workflows/e2e.yml/badge.svg)](https://github.com/s-frick/effigenix/actions/workflows/e2e.yml)
ERP-System für Fleischereien mit HACCP-Compliance, GoBD-konform, Mehrfilialen-Support.
## Schnellstart

View file

@ -54,6 +54,10 @@ test-backend:
test-e2e:
docker compose -f test-automation/docker-compose.e2e.yml up --build --abort-on-container-exit --exit-code-from e2e-runner
# Playwright-Spec-Stub aus GitHub Issue generieren (z.B. just generate-test 65)
generate-test ISSUE:
cd test-automation/web-ui && pnpm exec tsx scripts/generate-tests-from-issue.ts {{ISSUE}}
# ─── Code Generation ─────────────────────────────────────
# OpenAPI Spec + TypeScript Types generieren

View file

@ -57,8 +57,8 @@ Konzept: [`docs/ui-testing-automation.md`](./docs/ui-testing-automation.md)
- [x] `tests/api/inventory/movements.spec.ts`
- [x] `tests/api/inventory/reservations.spec.ts`
- [x] `tests/api/inventory/inventory-counts.spec.ts`
- [ ] Test-Generierungs-Skript (`scripts/generate-tests-from-issue.ts`) finalisieren
- [ ] Skript in `justfile` als `just generate-test <issue-nr>` integrieren
- [x] Test-Generierungs-Skript (`scripts/generate-tests-from-issue.ts`) finalisieren
- [x] Skript in `justfile` als `just generate-test <issue-nr>` integrieren
- [ ] Alle Specs im Docker-Stack grün
---
@ -67,11 +67,11 @@ Konzept: [`docs/ui-testing-automation.md`](./docs/ui-testing-automation.md)
> GitHub Actions Workflow für automatische Test-Ausführung.
- [ ] `.github/workflows/e2e.yml` erstellen
- [ ] Trigger: Push auf `main`, PRs gegen `main`
- [ ] JUnit-Report als CI-Artefakt hochladen
- [ ] Playwright HTML-Report als GitHub Pages veröffentlichen (optional)
- [ ] Badge in README einbinden
- [x] `.github/workflows/e2e.yml` erstellen
- [x] Trigger: Push auf `main`, PRs gegen `main` (+ `workflow_dispatch`)
- [x] JUnit-Report als CI-Artefakt hochladen (30 Tage Retention)
- [x] Playwright HTML-Report als CI-Artefakt hochladen (7 Tage Retention)
- [x] Badge in README einbinden
---
@ -91,8 +91,9 @@ Konzept: [`docs/ui-testing-automation.md`](./docs/ui-testing-automation.md)
| Punkt | Status | Massnahme |
|---|---|---|
| Backend `Dockerfile` | Fehlt | In Phase 1 erstellen |
| Seed-Testdaten Isolation | Offen | Strategie in Phase 1 klären (DB-Reset vor Suite oder pro Test) |
| `gh` Token `read:project` Scope | Fehlt | `gh auth refresh -s read:project` ausführen wenn nötig |
| TUI-Tests Abgrenzung | Klar | Vitest + ink-testing-library, gemockte API-Calls |
| Backend `Dockerfile` | ✅ Erledigt | Multi-stage Maven + JRE, `SPRING_PROFILES_ACTIVE: e2e` |
| Seed-Testdaten Isolation | ✅ Erledigt | Option B: UUID/Timestamp-Suffixe, kein DB-Reset nötig |
| `gh` Token `read:project` Scope | Offen | `gh auth refresh -s read:project` ausführen wenn nötig |
| TUI-Tests Abgrenzung | Klar | Vitest + ink-testing-library, gemockte API-Calls |
| Scanner (Tauri/mobil) | Out of scope | Separates Konzept, ggf. `test-automation/scanner/` später |
| Lokaler E2E-Run verifiziert | Offen | `just test-e2e` ausführen (benötigt Docker + ~5 Min. Maven-Build) |

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);
});