diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..31f1381 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/README.md b/README.md index b04198c..7e6963c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/justfile b/justfile index f9a57cc..cb3495a 100644 --- a/justfile +++ b/justfile @@ -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 diff --git a/test-automation/TASKS.md b/test-automation/TASKS.md index 1fb324c..508d7b3 100644 --- a/test-automation/TASKS.md +++ b/test-automation/TASKS.md @@ -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 ` integrieren +- [x] Test-Generierungs-Skript (`scripts/generate-tests-from-issue.ts`) finalisieren +- [x] Skript in `justfile` als `just generate-test ` 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) | diff --git a/test-automation/web-ui/package.json b/test-automation/web-ui/package.json index 5f884a6..d8e9359 100644 --- a/test-automation/web-ui/package.json +++ b/test-automation/web-ui/package.json @@ -12,6 +12,7 @@ "devDependencies": { "@playwright/test": "^1.51.0", "@types/node": "^20.0.0", + "tsx": "^4.19.0", "typescript": "^5.4.0" } } diff --git a/test-automation/web-ui/scripts/generate-tests-from-issue.ts b/test-automation/web-ui/scripts/generate-tests-from-issue.ts new file mode 100644 index 0000000..e19929d --- /dev/null +++ b/test-automation/web-ui/scripts/generate-tests-from-issue.ts @@ -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 = { + '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); +});