shinkan-jinkendo/tests/dev-smoke-test.spec.js
Lars cfab5c2d69
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 32s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 37s
fix(tests): stabilize login() helper with waitForURL before navigation
Replace waitForLoadState('networkidle') with waitForURL (not /login) to
ensure React auth processing completes before tests proceed with
page.goto(). Prevents race condition where page.goto() interrupted
in-progress token storage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 11:36:10 +02:00

315 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const { test, expect } = require('@playwright/test');
const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com';
const TEST_PASSWORD = process.env.TEST_PASSWORD || '12345678';
/** Primärer Submit auf der Login-Seite (nicht den Tab "Login" vs. "Registrieren"). */
async function submitLoginForm(page) {
await page.getByRole('button', { name: 'Anmelden' }).click();
}
async function login(page) {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Warte bis Login-Seite geladen ist
await page.waitForSelector('input[type="email"]', { timeout: 10000 });
await page.fill('input[type="email"]', TEST_EMAIL);
await page.fill('input[type="password"]', TEST_PASSWORD);
await submitLoginForm(page);
// Wait until auth is complete: URL leaves /login and Dashboard is rendered
await page.waitForURL((url) => !url.toString().includes('/login'), { timeout: 15000 });
await page.waitForLoadState('networkidle');
}
test('1. Login funktioniert', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('input[type="email"]', { timeout: 10000 });
await page.fill('input[type="email"]', TEST_EMAIL);
await page.fill('input[type="password"]', TEST_PASSWORD);
await submitLoginForm(page);
await page.waitForLoadState('networkidle');
// Nach Login soll der Tab "Login" (Moduswahl) verschwinden — nicht der Submit "Anmelden"
const loginButton = page.locator('button:has-text("Login")');
await expect(loginButton).toHaveCount(0, { timeout: 10000 });
await page.screenshot({ path: 'screenshots/01-nach-login.png' });
console.log('✓ Login erfolgreich');
});
test('2. Dashboard lädt ohne Fehler', async ({ page }) => {
await login(page);
// Warte bis Spinner verschwunden
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
// Dashboard: h1 „Dashboard“ + Begrüßungstext (nicht mehr „Willkommen bei Shinkan“ als Überschrift)
const main = page.locator('.app-main');
await expect(main.getByRole('heading', { level: 1, name: 'Dashboard' })).toBeVisible({
timeout: 5000,
});
await expect(main.getByText(/Shinkan unterstützt dich/i)).toBeVisible({ timeout: 5000 });
await page.screenshot({ path: 'screenshots/02-dashboard.png' });
console.log('✓ Dashboard OK');
});
test('3. Navigation zu Übungen', async ({ page }) => {
await login(page);
// Bei Viewport ≥1024px ist .bottom-nav versteckt — Mobile garantieren wie in playwright.config.js
await page.setViewportSize({ width: 390, height: 844 });
// Desktop-Sidebar enthält ebenfalls Übungen nur Mobile-Bottom-Nav klicken (sichtbarer Link)
await page.locator('.bottom-nav a[href="/exercises"]').click();
await page.waitForLoadState('networkidle');
// Prüfe ob Übungen-Seite geladen
await expect(page.locator('h1, h2, .page-title')).toContainText(/übungen/i, { timeout: 5000 });
await page.screenshot({ path: 'screenshots/03-uebungen.png' });
console.log('✓ Übungen-Seite erreichbar');
});
test('4. Navigation zu Vereine', async ({ page }) => {
await login(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.locator('.bottom-nav a[href="/clubs"]').click();
await page.waitForLoadState('networkidle');
// ClubsPage: <h1>Vereinsverwaltung</h1> + Tab <h2>Vereine</h2> → ein kombinierter
// Selektor löst 2 Treffer aus (Playwright strict mode). URL + primäre Überschrift reichen.
await expect(page).toHaveURL(/\/clubs\/?$/, { timeout: 5000 });
await expect(page.getByRole('heading', { level: 1, name: /Vereinsverwaltung/i })).toBeVisible({
timeout: 5000,
});
await page.screenshot({ path: 'screenshots/04-vereine.png' });
console.log('✓ Vereine-Seite erreichbar');
});
test('5. Desktop-Sidebar sichtbar (Desktop)', async ({ page }) => {
// Desktop-Viewport
await page.setViewportSize({ width: 1280, height: 800 });
await login(page);
// Prüfe ob Desktop-Sidebar existiert
const sidebar = page.locator('.desktop-sidebar');
await expect(sidebar).toBeVisible({ timeout: 5000 });
await page.screenshot({ path: 'screenshots/05-desktop-sidebar.png' });
console.log('✓ Desktop-Sidebar sichtbar');
});
test('6. Bottom-Nav sichtbar (Mobile)', async ({ page }) => {
// Mobile-Viewport
await page.setViewportSize({ width: 390, height: 844 });
await login(page);
// Prüfe ob Bottom-Nav existiert
const bottomNav = page.locator('.bottom-nav');
await expect(bottomNav).toBeVisible({ timeout: 5000 });
await page.screenshot({ path: 'screenshots/06-mobile-bottom-nav.png' });
console.log('✓ Bottom-Nav sichtbar');
});
test('7. Session-Persistenz nach Reload', async ({ page }) => {
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await expect(
page.locator('.app-main').getByRole('heading', { level: 1, name: 'Dashboard' }),
).toBeVisible({ timeout: 10000 });
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle');
// Auth lädt erst nach Spinner nicht auf /login stranden (stabiler als Button „Login“-Tab auf Login-Screen)
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 20000 });
await expect(page).not.toHaveURL(/\/login(?:\/|$|\?|#)/, { timeout: 20000 });
await expect(
page.locator('.app-main').getByRole('heading', { level: 1, name: 'Dashboard' }),
).toBeVisible({
timeout: 20000,
});
await page.screenshot({ path: 'screenshots/07-nach-reload.png' });
console.log('✓ Session bleibt nach Reload erhalten');
});
test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await login(page);
await page.waitForLoadState('networkidle');
// Coach-Sitzungsdaten simulieren (wie sie TrainingCoachPage schreibt)
await page.evaluate(() => {
sessionStorage.setItem('sj_coach_step_42', '3');
sessionStorage.setItem('sj_coach_deltas_42', '[{"id":1}]');
sessionStorage.setItem('sj_coach_debrief_42', '0');
sessionStorage.setItem('fremd_anderer_key', 'muss_erhalten_bleiben');
});
const vorLogout = await page.evaluate(() => ({
step: sessionStorage.getItem('sj_coach_step_42'),
fremd: sessionStorage.getItem('fremd_anderer_key'),
}));
expect(vorLogout.step).toBe('3');
expect(vorLogout.fremd).toBe('muss_erhalten_bleiben');
// App.jsx DesktopSidebar-Logout zeigt confirm() — in Playwright headless akzeptieren
page.once('dialog', dialog => dialog.accept());
await page.getByRole('button', { name: 'Abmelden' }).click();
await page.waitForLoadState('networkidle');
const nachLogout = await page.evaluate(() => ({
step: sessionStorage.getItem('sj_coach_step_42'),
deltas: sessionStorage.getItem('sj_coach_deltas_42'),
debrief: sessionStorage.getItem('sj_coach_debrief_42'),
fremd: sessionStorage.getItem('fremd_anderer_key'),
token: localStorage.getItem('authToken'),
}));
expect(nachLogout.step).toBeNull();
expect(nachLogout.deltas).toBeNull();
expect(nachLogout.debrief).toBeNull();
expect(nachLogout.fremd).toBe('muss_erhalten_bleiben');
expect(nachLogout.token).toBeNull();
await page.screenshot({ path: 'screenshots/p12-session-storage-nach-logout.png' });
console.log('✓ P-12: sj_coach_* entfernt, Fremd-Key erhalten, authToken entfernt');
});
// P-01: Rechtstextseiten öffentliche Routen ohne Auth
const LEGAL_ROUTES = [
{ path: '/impressum', label: 'Impressum' },
{ path: '/datenschutz', label: 'Datenschutz' },
{ path: '/nutzungsbedingungen', label: 'Nutzungsbedingungen' },
{ path: '/medienrichtlinie', label: 'Medienrichtlinie' },
];
for (const route of LEGAL_ROUTES) {
test(`P-01: ${route.label} ohne Auth erreichbar und enthält Platzhalterhinweis`, async ({ page }) => {
// Direkt aufrufen ohne Login
await page.goto(route.path);
await page.waitForLoadState('networkidle');
// Seite ist erreichbar (kein Redirect zur Login-Seite)
expect(page.url()).toContain(route.path);
// Platzhalterhinweis sichtbar
const hinweis = await page.getByText('MUSTER / PLATZHALTER').first();
await expect(hinweis).toBeVisible();
// Seitentitel korrekt
await expect(page.getByRole('heading', { level: 1 })).toContainText(route.label);
// Reload funktioniert
await page.reload();
await page.waitForLoadState('networkidle');
expect(page.url()).toContain(route.path);
console.log(`✓ P-01: ${route.label} ohne Auth erreichbar, Platzhalter sichtbar, Reload OK`);
});
}
test('P-01: Login-Seite enthält Links zu allen vier Rechtstextseiten', async ({ page }) => {
await page.goto('/login');
await page.waitForLoadState('networkidle');
for (const route of LEGAL_ROUTES) {
const link = page.locator(`a[href="${route.path}"]`);
await expect(link).toBeVisible();
}
console.log('✓ P-01: Login-Seite alle vier Rechtstext-Links vorhanden');
});
// P-01b: Rechtliches über Einstellungen (Mobile/PWA-Erreichbarkeit)
async function gotoAuthenticated(page, path) {
await page.goto(path);
await page.waitForLoadState('networkidle');
// After full-page reload the app re-checks auth from localStorage; wait for spinner to clear
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 15000 });
}
test('P-01b: Einstellungen enthält Link zu Rechtliches', async ({ page }) => {
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await gotoAuthenticated(page, '/settings');
await expect(page.locator('.app-main').getByRole('heading', { level: 1, name: 'Einstellungen' })).toBeVisible({ timeout: 8000 });
const link = page.locator('.app-main a[href="/settings/legal"]');
await expect(link).toBeVisible();
console.log('✓ P-01b: Einstellungen enthält Link zu /settings/legal');
});
test('P-01b: /settings/legal enthält Links zu allen vier Rechtstextseiten', async ({ page }) => {
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await gotoAuthenticated(page, '/settings/legal');
await expect(page.locator('.app-main').getByRole('heading', { level: 1, name: 'Rechtliches' })).toBeVisible({ timeout: 8000 });
for (const route of LEGAL_ROUTES) {
const link = page.locator(`.app-main a[href="${route.path}"]`);
await expect(link).toBeVisible();
}
console.log('✓ P-01b: /settings/legal Überschrift + alle vier Rechtstext-Links vorhanden');
});
test('P-01b: Jeder Rechtstext-Link aus /settings/legal führt zur korrekten Route', async ({ page }) => {
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
for (const route of LEGAL_ROUTES) {
await gotoAuthenticated(page, '/settings/legal');
await expect(page.locator('.app-main').getByRole('heading', { level: 1, name: 'Rechtliches' })).toBeVisible({ timeout: 8000 });
await page.locator(`.app-main a[href="${route.path}"]`).click();
await page.waitForLoadState('networkidle');
expect(page.url()).toContain(route.path);
await expect(page.getByRole('heading', { level: 1 })).toContainText(route.label);
console.log(`✓ P-01b: ${route.label} Link aus /settings/legal korrekt`);
}
});
test('8. Keine kritischen Console-Fehler', async ({ page }) => {
const errors = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
await login(page);
await page.waitForLoadState('networkidle');
// Filtere unkritische Fehler
const kritisch = errors.filter(e =>
!e.includes('favicon') &&
!e.includes('sourceMap') &&
!e.includes('404') &&
!e.includes('vite.svg')
);
if (kritisch.length > 0) {
console.log('⚠ Console-Fehler:', kritisch.join(', '));
} else {
console.log('✓ Keine kritischen Console-Fehler');
}
expect(kritisch.length).toBe(0);
});