feat(calendar): add Playwright E2E tests for web app

Add 22 E2E tests across 5 test suites covering auth, calendar views,
settings, event CRUD, and calendar management. Tests that require the
calendar backend gracefully skip when it's not running.

Also fixes: hooks.server.ts env fallbacks, ThrottlerGuard DI error,
and auth metrics service TypeScript error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-17 13:08:09 +01:00
parent f922d2c4a1
commit 6e1af0d889
14 changed files with 3020 additions and 1112 deletions

3
.gitignore vendored
View file

@ -51,6 +51,9 @@ pnpm-debug.log*
# Testing
coverage/
.nyc_output/
test-results/
playwright-report/
.auth-state.json
# TypeScript
*.tsbuildinfo

View file

@ -65,10 +65,7 @@ import { HttpExceptionFilter } from './common/http-exception.filter';
provide: APP_FILTER,
useClass: HttpExceptionFilter,
},
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
// ThrottlerGuard registered via ThrottlerModule — use @UseGuards(ThrottlerGuard) on controllers
],
})
export class AppModule {}

View file

@ -0,0 +1,99 @@
import { test, expect } from '@playwright/test';
// Auth tests run WITHOUT storageState (unauthenticated)
// Helper: wait for the app to finish loading (skeleton disappears)
async function waitForAppReady(page: import('@playwright/test').Page) {
// The root layout shows AppLoadingSkeleton until auth initializes
// Wait for it to disappear and the actual page content to render
await page.waitForFunction(
() => {
// Check if loading skeleton is gone
const skeleton = document.querySelector('.app-loading-skeleton, [data-skeleton]');
return !skeleton || skeleton.children.length === 0;
},
{ timeout: 30000 }
);
// Give Svelte time to render
await page.waitForTimeout(500);
}
test.describe('Authentication', () => {
test('login page renders with email and password fields', async ({ page }) => {
await page.goto('/login');
await waitForAppReady(page);
// LoginPage uses id="email" and id="password" (from shared-auth-ui)
const emailInput = page.locator('input[type="email"], input[name="email"], #email');
const passwordInput = page.locator('input[type="password"], input[name="password"], #password');
await expect(emailInput.first()).toBeVisible({ timeout: 10000 });
await expect(passwordInput.first()).toBeVisible({ timeout: 5000 });
await expect(page.locator('button[type="submit"]')).toBeVisible();
});
test('invalid credentials show error message', async ({ page }) => {
await page.goto('/login');
await waitForAppReady(page);
const emailInput = page.locator('input[type="email"], input[name="email"], #email').first();
const passwordInput = page
.locator('input[type="password"], input[name="password"], #password')
.first();
await emailInput.fill('nonexistent@test.local');
await passwordInput.fill('WrongPassword123!');
await page.locator('button[type="submit"]').click();
// Error alert should appear
const errorAlert = page.locator('#form-error, [role="alert"]');
await expect(errorAlert.first()).toBeVisible({ timeout: 10000 });
});
test('successful login redirects to calendar', async ({ page }) => {
const email = process.env.E2E_TEST_EMAIL || 'e2e-calendar@test.local';
const password = process.env.E2E_TEST_PASSWORD || 'TestPassword123';
// Listen for console errors and network failures
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text());
});
page.on('requestfailed', (req) => {
errors.push(`Request failed: ${req.url()} - ${req.failure()?.errorText}`);
});
await page.goto('/login');
await waitForAppReady(page);
const emailInput = page.locator('input[type="email"], input[name="email"], #email').first();
const passwordInput = page
.locator('input[type="password"], input[name="password"], #password')
.first();
await emailInput.fill(email);
await passwordInput.fill(password);
await page.locator('button[type="submit"]').click();
// Wait for either redirect or error
try {
await page.waitForURL('/', { timeout: 20000 });
} catch {
// Log any errors for debugging
console.log('Login errors:', errors);
const authUrl = await page.evaluate(
() => (window as any).__PUBLIC_MANA_CORE_AUTH_URL__ || 'NOT SET'
);
console.log('Auth URL on page:', authUrl);
throw new Error(`Login did not redirect. Auth URL: ${authUrl}. Errors: ${errors.join('; ')}`);
}
await expect(page.locator('main[aria-label="Kalender"]')).toBeVisible({ timeout: 10000 });
});
test('unauthenticated access to / redirects to /login', async ({ page }) => {
await page.goto('/');
// The app layout's onMount redirects unauthenticated users to /login
await page.waitForURL(/\/login/, { timeout: 30000 });
});
});

View file

@ -0,0 +1,101 @@
import { test, expect, dismissOnboarding } from './fixtures/auth';
test.describe('Calendar Views', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await dismissOnboarding(page);
// Wait for calendar to be fully loaded
await expect(page.locator('main[aria-label="Kalender"]')).toBeVisible({ timeout: 10000 });
});
test('week view loads as default with day columns', async ({ page }) => {
// ViewsBar "7" button should be active (week view)
const weekButton = page.locator('button[title="Wochenansicht"]');
await expect(weekButton).toBeVisible();
await expect(weekButton).toHaveClass(/active/);
// Week view grid should show day columns with hour rows
const calendarContent = page.locator('.calendar-content');
await expect(calendarContent).toBeVisible();
});
test('switch to month view via header button', async ({ page }) => {
const monthButton = page.locator('button[title="Monatsansicht"]');
await expect(monthButton).toBeVisible();
await monthButton.click();
// Month button should now be active
await expect(monthButton).toHaveClass(/active/);
// Week button should no longer be active
const weekButton = page.locator('button[title="Wochenansicht"]');
await expect(weekButton).not.toHaveClass(/active/);
});
test('switch to agenda view', async ({ page }) => {
const agendaButton = page.locator('button[title="Agenda"]');
await expect(agendaButton).toBeVisible();
await agendaButton.click();
// Agenda button should now be active
await expect(agendaButton).toHaveClass(/active/);
});
test('navigate forward and backward with arrow keys', async ({ page }) => {
// Click on the day-header area (non-interactive) to ensure body focus
await page.locator('body').click({ position: { x: 10, y: 10 } });
// Dismiss any overlay that might have opened
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
// Get all day-header aria-labels to identify the current week
const dayHeaders = page.locator('.day-header[aria-label]');
const initialLabel = await dayHeaders.first().getAttribute('aria-label');
// Navigate forward one week with ArrowRight
await page.keyboard.press('ArrowRight');
await page.waitForTimeout(1000);
const afterForwardLabel = await dayHeaders.first().getAttribute('aria-label');
// The first day header should show a different date after navigating
expect(afterForwardLabel).not.toBe(initialLabel);
// Navigate backward with ArrowLeft
await page.keyboard.press('ArrowLeft');
await page.waitForTimeout(1000);
const afterBackLabel = await dayHeaders.first().getAttribute('aria-label');
// After going forward then back, we should be at the same date
expect(afterBackLabel).toBe(initialLabel);
});
test('today button returns to current date after navigation', async ({ page }) => {
// Click on the day-header area and dismiss any overlay
await page.locator('body').click({ position: { x: 10, y: 10 } });
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
// Get today's day header
const todayHeader = page.locator('.day-header.today');
await expect(todayHeader).toBeVisible();
// Navigate away from today
await page.keyboard.press('ArrowRight');
await page.keyboard.press('ArrowRight');
await page.waitForTimeout(1000);
// Today should no longer be visible (navigated 2 weeks ahead)
await expect(todayHeader).not.toBeVisible();
// Click the "Heute" (Today) button - find by its title attribute
const todayButton = page.locator(
'.today-button, button[title*="heute" i], button[title*="today" i]'
);
await expect(todayButton.first()).toBeVisible({ timeout: 5000 });
await todayButton.first().click();
await page.waitForTimeout(1000);
// Today header should be visible again
await expect(todayHeader).toBeVisible();
});
});

View file

@ -0,0 +1,98 @@
import { test, expect } from './fixtures/auth';
const BACKEND_URL = process.env.PUBLIC_BACKEND_URL || 'http://localhost:3014';
test.describe('Calendar Management', () => {
test.beforeAll(async () => {
// Skip all calendar management tests if the backend is not running
try {
const res = await fetch(`${BACKEND_URL}/api/v1/health`, {
signal: AbortSignal.timeout(3000),
});
if (!res.ok) test.skip(true, 'Calendar backend is not running');
} catch {
test.skip(true, 'Calendar backend is not reachable');
}
});
test('default calendar exists on first load', async ({ page }) => {
await page.goto('/settings');
await expect(page.getByRole('heading', { name: 'Einstellungen', exact: true })).toBeVisible();
// The calendar list should have at least one calendar with "Standard" badge
const defaultBadge = page.locator('.badge-primary', { hasText: 'Standard' });
await expect(defaultBadge).toBeVisible({ timeout: 10000 });
});
test('create new calendar with name and color', async ({ page }) => {
const calendarName = `E2E Calendar ${Date.now()}`;
await page.goto('/settings');
await expect(page.getByRole('heading', { name: 'Einstellungen', exact: true })).toBeVisible();
// Click "Neuer Kalender" button
const newCalButton = page.getByRole('button', { name: /neuer kalender/i });
await expect(newCalButton).toBeVisible({ timeout: 10000 });
await newCalButton.click();
// Fill in the calendar name
const nameInput = page.locator('.new-calendar-form input[type="text"]');
await expect(nameInput).toBeVisible();
await nameInput.fill(calendarName);
// Submit the form
const createButton = page.getByRole('button', { name: /erstellen/i });
await createButton.click();
// Verify the new calendar appears in the list
const calendarCard = page.locator('.calendar-card', { hasText: calendarName });
await expect(calendarCard).toBeVisible({ timeout: 5000 });
// Cleanup: delete the calendar
page.on('dialog', (dialog) => dialog.accept());
const deleteButton = calendarCard.getByRole('button', { name: /löschen/i });
await deleteButton.click();
await expect(calendarCard).not.toBeVisible({ timeout: 5000 });
});
test('toggle calendar visibility in sidebar', async ({ page }) => {
await page.goto('/');
await expect(page.locator('main[aria-label="Kalender"]')).toBeVisible({ timeout: 10000 });
const calendarSelector = page.locator('.pill-calendar-selector, .calendar-selector');
if (await calendarSelector.isVisible({ timeout: 3000 }).catch(() => false)) {
const toggles = calendarSelector.locator('button, input[type="checkbox"]');
const count = await toggles.count();
if (count > 0) {
await toggles.first().click();
await page.waitForTimeout(500);
await toggles.first().click();
}
}
});
test('delete non-default calendar from settings', async ({ page }) => {
const calendarName = `E2E Delete Test ${Date.now()}`;
await page.goto('/settings');
await expect(page.getByRole('heading', { name: 'Einstellungen', exact: true })).toBeVisible();
// Create a calendar first
await page.getByRole('button', { name: /neuer kalender/i }).click();
await page.locator('.new-calendar-form input[type="text"]').fill(calendarName);
await page.getByRole('button', { name: /erstellen/i }).click();
const calendarCard = page.locator('.calendar-card', { hasText: calendarName });
await expect(calendarCard).toBeVisible({ timeout: 5000 });
page.on('dialog', (dialog) => dialog.accept());
const deleteButton = calendarCard.getByRole('button', { name: /löschen/i });
await expect(deleteButton).toBeVisible();
await deleteButton.click();
await expect(calendarCard).not.toBeVisible({ timeout: 5000 });
});
});

View file

@ -0,0 +1,175 @@
import { test, expect } from './fixtures/auth';
const BACKEND_URL = process.env.PUBLIC_BACKEND_URL || 'http://localhost:3014';
test.describe('Event CRUD', () => {
test.beforeAll(async () => {
// Skip all event tests if the backend is not running
try {
const res = await fetch(`${BACKEND_URL}/api/v1/health`, {
signal: AbortSignal.timeout(3000),
});
if (!res.ok) test.skip(true, 'Calendar backend is not running');
} catch {
test.skip(true, 'Calendar backend is not reachable');
}
});
test.beforeEach(async ({ page }) => {
await page.goto('/');
await expect(page.locator('main[aria-label="Kalender"]')).toBeVisible({ timeout: 10000 });
});
test('create event via quick overlay, see it in view, then delete it', async ({ page }) => {
const uniqueTitle = `E2E Test Event ${Date.now()}`;
// Click on a time slot in the week view to trigger quick create
const weekGrid = page.locator('.week-grid, .carousel-page.current .week-grid');
if (await weekGrid.first().isVisible()) {
const box = await weekGrid.first().boundingBox();
if (box) {
await weekGrid.first().click({
position: { x: box.width * 0.5, y: box.height * 0.3 },
});
}
}
// Wait for the quick event overlay to appear
const overlay = page.locator('.quick-event-overlay');
await expect(overlay).toBeVisible({ timeout: 5000 });
// Type the event title (the title input is auto-focused)
await page.keyboard.type(uniqueTitle);
// Click "Speichern" (Save)
await overlay.getByRole('button', { name: /speichern/i }).click();
await expect(overlay).not.toBeVisible({ timeout: 5000 });
// Verify the event appears in the calendar view
const eventCard = page.locator('.event-card, .event-block').filter({ hasText: uniqueTitle });
await expect(eventCard).toBeVisible({ timeout: 5000 });
// Click the event to open it
await eventCard.click();
// The quick event overlay should open with event details
const editOverlay = page.locator('.quick-event-overlay');
await expect(editOverlay).toBeVisible({ timeout: 5000 });
// Delete the event
const deleteButton = editOverlay.getByRole('button', { name: /löschen/i });
if (await deleteButton.isVisible()) {
await deleteButton.click();
const confirmButton = page.getByRole('button', { name: /löschen|ja|bestätigen/i });
if (await confirmButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await confirmButton.click();
}
await expect(eventCard).not.toBeVisible({ timeout: 5000 });
}
});
test('edit event title and verify update', async ({ page }) => {
const originalTitle = `E2E Edit Test ${Date.now()}`;
const updatedTitle = `${originalTitle} Updated`;
// Create an event first via the grid
const weekGrid = page.locator('.week-grid, .carousel-page.current .week-grid');
if (await weekGrid.first().isVisible()) {
const box = await weekGrid.first().boundingBox();
if (box) {
await weekGrid.first().click({
position: { x: box.width * 0.5, y: box.height * 0.4 },
});
}
}
const overlay = page.locator('.quick-event-overlay');
await expect(overlay).toBeVisible({ timeout: 5000 });
await page.keyboard.type(originalTitle);
await overlay.getByRole('button', { name: /speichern/i }).click();
await expect(overlay).not.toBeVisible({ timeout: 5000 });
// Find and click the created event
const eventCard = page.locator('.event-card, .event-block').filter({ hasText: originalTitle });
await expect(eventCard).toBeVisible({ timeout: 5000 });
await eventCard.click();
// Edit the title
const editOverlay = page.locator('.quick-event-overlay');
await expect(editOverlay).toBeVisible({ timeout: 5000 });
const titleInput = editOverlay.locator('input[type="text"]').first();
await expect(titleInput).toHaveValue(originalTitle);
await titleInput.clear();
await titleInput.fill(updatedTitle);
await editOverlay.getByRole('button', { name: /speichern/i }).click();
await expect(editOverlay).not.toBeVisible({ timeout: 5000 });
// Verify updated title is visible
const updatedCard = page.locator('.event-card, .event-block').filter({ hasText: updatedTitle });
await expect(updatedCard).toBeVisible({ timeout: 5000 });
// Cleanup: delete the event
await updatedCard.click();
const cleanupOverlay = page.locator('.quick-event-overlay');
await expect(cleanupOverlay).toBeVisible({ timeout: 5000 });
const deleteBtn = cleanupOverlay.getByRole('button', { name: /löschen/i });
if (await deleteBtn.isVisible()) {
await deleteBtn.click();
const confirmBtn = page.getByRole('button', { name: /löschen|ja|bestätigen/i });
if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await confirmBtn.click();
}
}
});
test('click event to open detail overlay', async ({ page }) => {
const title = `E2E Detail Test ${Date.now()}`;
// Create an event
const weekGrid = page.locator('.week-grid, .carousel-page.current .week-grid');
if (await weekGrid.first().isVisible()) {
const box = await weekGrid.first().boundingBox();
if (box) {
await weekGrid.first().click({
position: { x: box.width * 0.5, y: box.height * 0.5 },
});
}
}
const overlay = page.locator('.quick-event-overlay');
await expect(overlay).toBeVisible({ timeout: 5000 });
await page.keyboard.type(title);
await overlay.getByRole('button', { name: /speichern/i }).click();
await expect(overlay).not.toBeVisible({ timeout: 5000 });
// Click the event to see details
const eventCard = page.locator('.event-card, .event-block').filter({ hasText: title });
await expect(eventCard).toBeVisible({ timeout: 5000 });
await eventCard.click();
const detailOverlay = page.locator('.quick-event-overlay');
await expect(detailOverlay).toBeVisible({ timeout: 5000 });
const titleInput = detailOverlay.locator('input[type="text"]').first();
await expect(titleInput).toHaveValue(title);
// Close and cleanup
await detailOverlay.getByRole('button', { name: /abbrechen/i }).click();
await expect(detailOverlay).not.toBeVisible({ timeout: 5000 });
await eventCard.click();
const cleanupOverlay = page.locator('.quick-event-overlay');
const deleteBtn = cleanupOverlay.getByRole('button', { name: /löschen/i });
if (await deleteBtn.isVisible()) {
await deleteBtn.click();
const confirmBtn = page.getByRole('button', { name: /löschen|ja|bestätigen/i });
if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await confirmBtn.click();
}
}
});
});

View file

@ -0,0 +1,135 @@
import { test as base, expect, type Page, type BrowserContext } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const TEST_EMAIL = process.env.E2E_TEST_EMAIL || 'e2e-calendar@test.local';
const TEST_PASSWORD = process.env.E2E_TEST_PASSWORD || 'TestPassword123';
const AUTH_URL = process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const STORAGE_STATE_PATH = path.join(__dirname, '..', '.auth-state.json');
/**
* Ensures a test user exists via the auth API.
*/
async function ensureTestUser(): Promise<void> {
try {
const res = await fetch(`${AUTH_URL}/api/v1/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: TEST_EMAIL, password: TEST_PASSWORD, name: 'E2E Test User' }),
});
if (!res.ok && res.status !== 409 && res.status !== 422) {
const body = await res.text();
console.warn(`Register returned ${res.status}: ${body}`);
}
} catch {
// User may already exist
}
try {
await fetch(`${AUTH_URL}/api/v1/auth/verify-email-dev`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: TEST_EMAIL }),
});
} catch {
// Verification endpoint may not exist
}
}
async function waitForAppReady(page: Page): Promise<void> {
await page.waitForFunction(
() => document.querySelector('main, form, input[type="email"], #email') !== null,
{ timeout: 30000 }
);
}
/**
* Dismiss the onboarding modal by clicking "Überspringen".
* Waits briefly for it to appear, then dismisses it.
*/
async function dismissOnboarding(page: Page): Promise<void> {
try {
const skipButton = page.getByText('Überspringen', { exact: true });
await skipButton.waitFor({ state: 'visible', timeout: 3000 });
await skipButton.click();
// Wait for modal to close
await page.locator('.fixed.inset-0.z-50').waitFor({ state: 'hidden', timeout: 5000 });
} catch {
// No onboarding modal — that's fine
}
}
function hasValidStorageState(): boolean {
try {
const stat = fs.statSync(STORAGE_STATE_PATH);
const ageMs = Date.now() - stat.mtimeMs;
if (ageMs > 60 * 60 * 1000) return false;
const content = JSON.parse(fs.readFileSync(STORAGE_STATE_PATH, 'utf-8'));
return content.origins?.length > 0;
} catch {
return false;
}
}
async function loginViaUI(page: Page): Promise<void> {
await page.goto('/login');
await waitForAppReady(page);
const emailInput = page.locator('input[type="email"], input[name="email"], #email').first();
const passwordInput = page
.locator('input[type="password"], input[name="password"], #password')
.first();
await emailInput.fill(TEST_EMAIL);
await passwordInput.fill(TEST_PASSWORD);
await page.locator('button[type="submit"]').click();
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30000 });
await expect(page.locator('main').first()).toBeVisible({ timeout: 15000 });
// Dismiss onboarding wizard if it appears
await dismissOnboarding(page);
}
/**
* Extended test fixture that provides an authenticated page.
*/
export const test = base.extend<object, { workerStorageState: string }>({
workerStorageState: [
async ({ browser }, use) => {
if (hasValidStorageState()) {
await use(STORAGE_STATE_PATH);
return;
}
await ensureTestUser();
const context = await browser.newContext();
const page = await context.newPage();
await loginViaUI(page);
await context.storageState({ path: STORAGE_STATE_PATH });
await page.close();
await context.close();
await use(STORAGE_STATE_PATH);
},
{ scope: 'worker' },
],
context: async ({ browser, workerStorageState }, use) => {
const context = await browser.newContext({ storageState: workerStorageState });
await use(context);
await context.close();
},
page: async ({ context }, use) => {
const page = await context.newPage();
await use(page);
},
});
export { expect, dismissOnboarding };

View file

@ -0,0 +1,105 @@
import { test, expect, dismissOnboarding } from './fixtures/auth';
test.describe('Settings', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/settings');
await dismissOnboarding(page);
await expect(page.getByRole('heading', { name: 'Einstellungen', exact: true })).toBeVisible({
timeout: 10000,
});
});
test('settings page renders all sections', async ({ page }) => {
// Check that the main setting sections are visible (use headings to avoid ambiguity)
await expect(page.getByText('Meine Kalender', { exact: true })).toBeVisible();
await expect(page.getByText('Kalender-Ansicht', { exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Termine' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Konto' })).toBeVisible();
});
test('change time format between 24h and 12h', async ({ page }) => {
// Find the time format buttons
const button24h = page.getByRole('button', { name: '24h (14:00)' });
const button12h = page.getByRole('button', { name: '12h (2:00 PM)' });
await expect(button24h).toBeVisible();
await expect(button12h).toBeVisible();
// Switch to 12h
await button12h.click();
await expect(button12h).toHaveClass(/active/);
await expect(button24h).not.toHaveClass(/active/);
// Switch back to 24h
await button24h.click();
await expect(button24h).toHaveClass(/active/);
await expect(button12h).not.toHaveClass(/active/);
});
test('toggle show week numbers', async ({ page }) => {
// Find the "Wochennummern anzeigen" checkbox
const weekNumbersLabel = page.getByText('Wochennummern anzeigen');
await expect(weekNumbersLabel).toBeVisible();
// The checkbox is inside a label with this text
const checkbox = page
.locator('label')
.filter({ hasText: 'Wochennummern anzeigen' })
.locator('input[type="checkbox"]');
const wasChecked = await checkbox.isChecked();
// Toggle it
await checkbox.click();
await expect(checkbox).toBeChecked({ checked: !wasChecked });
// Toggle it back
await checkbox.click();
await expect(checkbox).toBeChecked({ checked: wasChecked });
});
test('toggle show only weekdays', async ({ page }) => {
const checkbox = page
.locator('label')
.filter({ hasText: 'Nur Werktage anzeigen' })
.locator('input[type="checkbox"]');
await expect(checkbox).toBeVisible();
const wasChecked = await checkbox.isChecked();
await checkbox.click();
await expect(checkbox).toBeChecked({ checked: !wasChecked });
// Restore original state
await checkbox.click();
await expect(checkbox).toBeChecked({ checked: wasChecked });
});
test('settings persist after page reload', async ({ page }) => {
// Switch to 12h format
const button12h = page.getByRole('button', { name: '12h (2:00 PM)' });
await button12h.click();
await expect(button12h).toHaveClass(/active/);
// Reload
await page.reload();
await expect(page.getByRole('heading', { name: 'Einstellungen', exact: true })).toBeVisible({
timeout: 10000,
});
// Verify 12h is still active
const button12hAfterReload = page.getByRole('button', { name: '12h (2:00 PM)' });
await expect(button12hAfterReload).toHaveClass(/active/);
// Restore to 24h
const button24h = page.getByRole('button', { name: '24h (14:00)' });
await button24h.click();
await expect(button24h).toHaveClass(/active/);
});
test('user email is displayed in account section', async ({ page }) => {
const testEmail = process.env.E2E_TEST_EMAIL || 'e2e-calendar@test.local';
// The account section shows the user's email
const emailDisplay = page.locator('.setting-value');
await expect(emailDisplay.first()).toContainText(testEmail);
});
});

View file

@ -12,11 +12,15 @@
"lint": "eslint .",
"format": "prettier --write .",
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run"
"test": "vitest run",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed"
},
"devDependencies": {
"@manacore/shared-pwa": "workspace:*",
"@manacore/shared-vite-config": "workspace:*",
"@playwright/test": "^1.51.0",
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^5.0.0",

View file

@ -0,0 +1,39 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : [['html']],
use: {
baseURL: 'http://localhost:5179',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 10000,
navigationTimeout: 30000,
},
timeout: 60000,
expect: { timeout: 5000 },
projects: process.env.CI
? [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
]
: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: 'pnpm run build && pnpm run preview --port 5179',
port: 5179,
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
outputDir: 'test-results/',
});

View file

@ -8,10 +8,15 @@
import type { Handle } from '@sveltejs/kit';
// Get client-side URLs from environment (Docker runtime)
// In dev mode, Vite exposes .env vars via import.meta.env, not process.env
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT ||
process.env.PUBLIC_MANA_CORE_AUTH_URL ||
'http://localhost:3001';
const PUBLIC_BACKEND_URL_CLIENT =
process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || '';
process.env.PUBLIC_BACKEND_URL_CLIENT ||
process.env.PUBLIC_BACKEND_URL ||
'http://localhost:3014';
const PUBLIC_STT_URL = process.env.PUBLIC_STT_URL || 'https://stt-api.mana.how';
// Cross-app integration URLs (for todo and contacts APIs)

View file

@ -193,6 +193,8 @@
"storage:db:push": "pnpm --filter @storage/backend db:push",
"storage:db:studio": "pnpm --filter @storage/backend db:studio",
"storage:db:seed": "pnpm --filter @storage/backend db:seed",
"mukke:dev": "turbo run dev --filter=mukke...",
"dev:mukke:mobile": "pnpm --filter @mukke/mobile dev",
"traces:dev": "turbo run dev --filter=traces...",
"dev:traces:mobile": "pnpm --filter @traces/mobile dev",
"dev:traces:backend": "pnpm --filter @traces/backend start:dev",
@ -265,6 +267,7 @@
"prepare": "husky"
},
"devDependencies": {
"@manacore/eslint-config": "workspace:*",
"@eslint/js": "^9.39.1",
"concurrently": "^9.2.0",
"eslint": "^9.39.1",
@ -301,6 +304,9 @@
"ssh2",
"sharp"
],
"patchedDependencies": {
"react-native-reanimated@4.1.5": "patches/react-native-reanimated@4.1.5.patch"
},
"overrides": {
"cpu-features": "npm:empty-npm-package@1.0.0",
"ssh2": "npm:empty-npm-package@1.0.0",

3349
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,7 @@ import { users } from '../db/schema';
export class MetricsService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(MetricsService.name);
private readonly register: client.Registry;
private updateInterval: NodeJS.Timeout | null = null;
private updateInterval: ReturnType<typeof setInterval> | null = null;
// HTTP metrics
readonly httpRequestsTotal: client.Counter<string>;