diff --git a/.gitignore b/.gitignore index 55e7229..26da15d 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,9 @@ coverage/ # Temp tmp/ *.tmp +test-results/ +screenshots/ +tests/.auth/ # Claude: nur ausgewählte Bereiche versionieren (siehe .claude/rules/DOCUMENTATION.md) .claude/** diff --git a/playwright.config.js b/playwright.config.js index 9edeed0..77fb387 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -1,12 +1,15 @@ module.exports = { testDir: './tests', timeout: 30000, + workers: 1, + globalSetup: require.resolve('./tests/global-setup.js'), use: { channel: 'chrome', headless: true, viewport: { width: 390, height: 844 }, screenshot: 'only-on-failure', baseURL: 'https://dev.mitai.jinkendo.de', + storageState: './tests/.auth/user.json', }, reporter: 'list', }; diff --git a/tests/dev-smoke-test.spec.js b/tests/dev-smoke-test.spec.js index 70c365c..9926a28 100644 --- a/tests/dev-smoke-test.spec.js +++ b/tests/dev-smoke-test.spec.js @@ -1,65 +1,57 @@ const { test, expect } = require('@playwright/test'); +const { loginViaForm, clickBottomNav } = require('./helpers'); -const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com'; -const TEST_PASSWORD = process.env.TEST_PASSWORD || '5112'; +test.describe('Login-Flow', () => { + test.use({ storageState: { cookies: [], origins: [] } }); -async function login(page) { - await page.goto('/'); - await page.waitForLoadState('networkidle'); - await page.fill('input[type="email"]', TEST_EMAIL); - await page.fill('input[type="password"]', TEST_PASSWORD); - await page.click('button:has-text("Anmelden")'); - await page.waitForLoadState('networkidle'); -} - -test('1. Login funktioniert', async ({ page }) => { - await page.goto('/'); - await page.fill('input[type="email"]', TEST_EMAIL); - await page.fill('input[type="password"]', TEST_PASSWORD); - await page.click('button:has-text("Anmelden")'); - await page.waitForLoadState('networkidle'); - const loginButton = page.locator('button:has-text("Anmelden")'); - await expect(loginButton).toHaveCount(0, { timeout: 10000 }); - await page.screenshot({ path: 'screenshots/01-nach-login.png' }); - console.log('Login erfolgreich'); + test('1. Login funktioniert', async ({ page }) => { + await loginViaForm(page); + await page.screenshot({ path: 'screenshots/01-nach-login.png' }); + console.log('Login erfolgreich'); + }); }); test('2. Dashboard laedt ohne Fehler', async ({ page }) => { - await login(page); + await page.goto('/'); + await page.waitForLoadState('networkidle'); await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 }); await page.screenshot({ path: 'screenshots/02-dashboard.png' }); console.log('Dashboard OK'); }); -test('3. Erfassung erreichbar', async ({ page }) => { - await login(page); - await page.click('text=Erfassung'); +test('3. Erfassen erreichbar', async ({ page }) => { + await page.goto('/'); await page.waitForLoadState('networkidle'); + await clickBottomNav(page, 'Erfassen'); + await page.waitForLoadState('networkidle'); + await expect(page).toHaveURL(/\/capture/); await page.screenshot({ path: 'screenshots/03-erfassung.png' }); - console.log('Erfassung OK'); + console.log('Erfassen OK'); }); test('4. Analyse erreichbar', async ({ page }) => { - await login(page); - await page.click('text=Analyse'); + await page.goto('/'); await page.waitForLoadState('networkidle'); + await clickBottomNav(page, 'Analyse'); + await page.waitForLoadState('networkidle'); + await expect(page).toHaveURL(/\/analysis/); await page.screenshot({ path: 'screenshots/04-analyse.png' }); console.log('Analyse OK'); }); test('5. Keine kritischen Console-Fehler', async ({ page }) => { const errors = []; - page.on('console', msg => { + page.on('console', (msg) => { if (msg.type() === 'error') errors.push(msg.text()); }); - await login(page); + await page.goto('/'); await page.waitForLoadState('networkidle'); - const kritisch = errors.filter(e => - !e.includes('favicon') && !e.includes('sourceMap') && !e.includes('404') + const kritisch = errors.filter( + (e) => !e.includes('favicon') && !e.includes('sourceMap') && !e.includes('404') ); if (kritisch.length > 0) { console.log('Console-Fehler:', kritisch.join(', ')); } else { console.log('Keine kritischen Console-Fehler'); } -}); \ No newline at end of file +}); diff --git a/tests/global-setup.js b/tests/global-setup.js new file mode 100644 index 0000000..80df893 --- /dev/null +++ b/tests/global-setup.js @@ -0,0 +1,64 @@ +const { chromium } = require('@playwright/test'); +const fs = require('fs'); +const path = require('path'); + +const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com'; +const TEST_PASSWORD = process.env.TEST_PASSWORD || '5112'; +const AUTH_FILE = path.join(__dirname, '.auth', 'user.json'); + +async function tryLogin(page, baseURL) { + await page.goto(`${baseURL}/`); + await page.waitForLoadState('networkidle'); + + const loginBtn = page.locator('button:has-text("Anmelden")'); + if ((await loginBtn.count()) === 0) { + return { ok: true, status: 200 }; + } + + await page.fill('input[type="email"]', TEST_EMAIL); + await page.fill('input[type="password"]', TEST_PASSWORD); + + const responsePromise = page + .waitForResponse( + (r) => r.url().includes('/api/auth/login') && r.request().method() === 'POST', + { timeout: 15000 } + ) + .catch(() => null); + + await page.click('button:has-text("Anmelden")'); + const response = await responsePromise; + await page.waitForLoadState('networkidle'); + + const loginVisible = await page.locator('button:has-text("Anmelden")').count(); + return { ok: loginVisible === 0, status: response?.status() ?? null }; +} + +module.exports = async (config) => { + fs.mkdirSync(path.dirname(AUTH_FILE), { recursive: true }); + + const browser = await chromium.launch({ channel: 'chrome', headless: true }); + const page = await browser.newPage(); + const baseURL = config.projects?.[0]?.use?.baseURL || 'https://dev.mitai.jinkendo.de'; + + const waits = [0, 15000, 30000, 45000]; + let lastStatus = null; + + for (let i = 0; i < waits.length; i++) { + if (waits[i] > 0) { + console.log(`Global setup: warte ${waits[i] / 1000}s (Login-Rate-Limit 5/min)…`); + await page.waitForTimeout(waits[i]); + } + const result = await tryLogin(page, baseURL); + lastStatus = result.status; + if (result.ok) { + await page.context().storageState({ path: AUTH_FILE }); + await browser.close(); + return; + } + } + + await browser.close(); + throw new Error( + `Global setup: Login fehlgeschlagen (letzter HTTP-Status: ${lastStatus ?? 'unbekannt'})` + ); +}; diff --git a/tests/helpers.js b/tests/helpers.js new file mode 100644 index 0000000..3f74bf3 --- /dev/null +++ b/tests/helpers.js @@ -0,0 +1,23 @@ +const { expect } = require('@playwright/test'); + +const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com'; +const TEST_PASSWORD = process.env.TEST_PASSWORD || '5112'; + +/** Nutzt storageState aus global-setup; nur für Tests nötig, die explizit Login prüfen. */ +async function loginViaForm(page) { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + await page.fill('input[type="email"]', TEST_EMAIL); + await page.fill('input[type="password"]', TEST_PASSWORD); + await page.click('button:has-text("Anmelden")'); + await page.waitForLoadState('networkidle'); + await expect(page.locator('button:has-text("Anmelden")')).toHaveCount(0, { timeout: 15000 }); +} + +async function clickBottomNav(page, label) { + const link = page.locator('nav.bottom-nav a.nav-item').filter({ hasText: label }); + await expect(link).toBeVisible({ timeout: 10000 }); + await link.click(); +} + +module.exports = { TEST_EMAIL, TEST_PASSWORD, loginViaForm, clickBottomNav }; diff --git a/tests/issue-audit.spec.js b/tests/issue-audit.spec.js index fe1d759..b9ea6d3 100644 --- a/tests/issue-audit.spec.js +++ b/tests/issue-audit.spec.js @@ -4,28 +4,15 @@ */ const { test, expect } = require('@playwright/test'); -const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com'; -const TEST_PASSWORD = process.env.TEST_PASSWORD || '5112'; - -async function login(page) { - await page.goto('/'); - await page.waitForLoadState('networkidle'); - await page.fill('input[type="email"]', TEST_EMAIL); - await page.fill('input[type="password"]', TEST_PASSWORD); - await page.click('button:has-text("Anmelden")'); - await page.waitForLoadState('networkidle'); - await expect(page.locator('button:has-text("Anmelden")')).toHaveCount(0, { timeout: 15000 }); -} - test.describe('Issue-Audit UI', () => { test('#40 Logout-Button im Mobile-Header sichtbar', async ({ page }) => { - await login(page); + await page.goto('/'); + await page.waitForLoadState('networkidle'); const logoutBtn = page.locator('header.app-header--mobile button[title="Abmelden"]'); await expect(logoutBtn).toBeVisible(); }); test('#25 Goals-Seite erreichbar und rendert Inhalt', async ({ page }) => { - await login(page); await page.goto('/goals'); await page.waitForLoadState('networkidle'); await expect(page.locator('body')).not.toContainText('404', { timeout: 5000 }); @@ -36,7 +23,6 @@ test.describe('Issue-Audit UI', () => { }); test('#65 Dashboard-Layout-Konfiguration erreichbar', async ({ page }) => { - await login(page); await page.goto('/settings/dashboard-layout'); await page.waitForLoadState('networkidle'); const body = await page.locator('body').innerText(); @@ -44,21 +30,19 @@ test.describe('Issue-Audit UI', () => { }); test('#30 Desktop-Sidebar bei breitem Viewport', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); await page.setViewportSize({ width: 1280, height: 800 }); - await login(page); - const sidebar = page.locator('.desktop-sidebar, [class*="desktop-sidebar"]'); - await expect(sidebar.first()).toBeVisible({ timeout: 10000 }); + await expect(page.locator('aside.desktop-sidebar')).toBeVisible({ timeout: 10000 }); }); test('#38 Nutrition CSV Import — FDDB-Panel mit Usage-Integration', async ({ page }) => { - await login(page); await page.goto('/nutrition'); await page.waitForLoadState('networkidle'); await page.getByRole('button', { name: /Import/i }).click(); await page.waitForLoadState('networkidle'); const importCard = page.locator('.card').filter({ hasText: 'FDDB CSV Import' }); await expect(importCard).toBeVisible(); - // Nach Deploy: badge-container-right am Titel; UsageBadge nur bei endlichem Limit (Premium = kein Badge) const titleRow = importCard.locator('.card-title.badge-container-right'); if (await titleRow.count()) { await expect(titleRow).toBeVisible();