import { expect, test, type Page } from '@playwright/test'; import { readFileSync, readdirSync } from 'node:fs'; import path from 'node:path'; const adminEmail = 'barkeeper@example.test'; const adminPassword = 'Nur-fuer-E2E-Admin-1234'; const crewEmail = 'crew@example.test'; const crewPassword = 'Nur-fuer-E2E-Crew-1234'; const routes = ['/fundus', '/sendungen', '/eingang', '/archiv', '/verwaltung', '/live']; const viewports = [ { width: 390, height: 844, theme: 'light' }, { width: 390, height: 844, theme: 'dark' }, { width: 768, height: 900, theme: 'light' }, { width: 768, height: 900, theme: 'dark' }, { width: 1440, height: 1000, theme: 'dark' }, { width: 1440, height: 1000, theme: 'light' } ] as const; async function expectNoHorizontalOverflow(page: Page) { const dimensions = await page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth })); expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth); } async function setTheme(page: Page, theme: 'light' | 'dark') { await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme); } async function login(page: Page, email = adminEmail, password = adminPassword) { await page.goto('/login'); await page.getByLabel('E-Mail').fill(email); await page.getByLabel('Passwort', { exact: true }).fill(password); await page.getByRole('button', { name: 'Anmelden' }).click(); await expect(page).toHaveURL(/\/fundus$/); } function latestMailLink(subject: string): string { const outbox = process.env.SENDEWERK_E2E_OUTBOX_DIR; if (!outbox) throw new Error('E2E-Outbox ist nicht konfiguriert.'); const messages = readdirSync(outbox) .sort() .map( (file) => JSON.parse(readFileSync(path.join(outbox, file), 'utf8')) as { subject: string; text: string; } ) .filter((message) => message.subject === subject); const match = messages.at(-1)?.text.match(/https?:\/\/[^\s]+/); if (!match) throw new Error(`Keine Testmail mit Betreff ${subject} gefunden.`); return match[0]; } test.beforeAll(async ({ browser }) => { const page = await browser.newPage(); await page.goto('/'); if (await page.getByRole('heading', { name: 'Ersten Barkeeper anlegen' }).isVisible()) { await page.getByLabel('Betreiber-Setup-Token').fill('e2e-only-setup-token-32-characters'); await page.getByLabel('Name').fill('E2E Barkeeper'); await page.getByLabel('E-Mail').fill(adminEmail); await page.getByLabel('Passwort').fill(adminPassword); await page.getByRole('button', { name: 'Barkeeper anlegen' }).click(); } else { await page.getByLabel('E-Mail').fill(adminEmail); await page.getByLabel('Passwort').fill(adminPassword); await page.getByRole('button', { name: 'Anmelden' }).click(); } await expect(page).toHaveURL(/\/fundus$/); const sessionCookie = (await page.context().cookies()).find( (cookie) => cookie.name === 'sendewerk_session' ); expect(sessionCookie?.httpOnly).toBe(true); expect(sessionCookie?.secure).toBe(false); expect(sessionCookie?.sameSite).toBe('Lax'); await page.goto('/setup'); await expect(page).toHaveURL(/\/fundus$/); await page.getByRole('button', { name: 'Abmelden', exact: true }).click(); await expect(page).toHaveURL(/\/login$/); await page.close(); }); test.beforeEach(async ({ page }) => { await login(page); }); test('invitations, direct authorization, locking and password reset work end to end', async ({ page, browser }, testInfo) => { await page.goto('/verwaltung'); await page.screenshot({ path: testInfo.outputPath('verwaltung-konten-desktop.png'), fullPage: true }); const crossSite = await page.request.post('/verwaltung?/invite', { form: { email: 'cross-site@example.test', role: 'barkeeper' }, headers: { origin: 'https://example.invalid' } }); expect(crossSite.status()).toBe(403); const inviteForm = page.locator('.invite-panel').filter({ hasText: 'Neues Konto einladen' }); await inviteForm.getByLabel('E-Mail').fill(crewEmail); await inviteForm.getByRole('button', { name: 'Einladung erstellen' }).click(); await expect( page.getByText('Einladung wurde in der lokalen Mail-Outbox abgelegt.') ).toBeVisible(); const crewContext = await browser.newContext(); const crewPage = await crewContext.newPage(); await crewPage.goto(latestMailLink('Einladung zu Sendewerk')); await expect(crewPage).toHaveURL(/\/einladung$/); await crewPage.getByLabel('Name').fill('E2E Crew'); await crewPage.getByLabel('Passwort').fill(crewPassword); await crewPage.getByRole('button', { name: 'Konto aktivieren' }).click(); await expect(crewPage).toHaveURL(/\/fundus$/); await expect(crewPage.getByText('Verwaltung', { exact: true })).toHaveCount(0); const forbidden = await crewPage.request.post('/verwaltung?/invite', { form: { email: 'verboten@example.test', role: 'barkeeper' }, headers: { origin: 'http://127.0.0.1:4173' } }); expect(forbidden.status()).toBe(403); await page.reload(); const crewRow = page.locator('.account-row').filter({ hasText: crewEmail }); await crewRow.getByRole('button', { name: 'Sperren' }).click(); await crewPage.goto('/fundus'); await expect(crewPage).toHaveURL(/\/login$/); await page.reload(); await page .locator('.account-row') .filter({ hasText: crewEmail }) .getByRole('button', { name: 'Entsperren' }) .click(); await crewPage.goto('/passwort-vergessen'); await crewPage.getByLabel('E-Mail').fill(crewEmail); await crewPage.getByRole('button', { name: 'Reset-Link anfordern' }).click(); await expect(crewPage.getByText('Anfrage verarbeitet.')).toBeVisible(); await crewPage.goto(latestMailLink('Sendewerk-Passwort zurücksetzen')); await expect(crewPage).toHaveURL(/\/passwort-zuruecksetzen$/); await crewPage.getByLabel('Neues Passwort').fill('Neues-E2E-Crew-Passwort-1234'); await crewPage.getByRole('button', { name: 'Passwort speichern' }).click(); await login(crewPage, crewEmail, 'Neues-E2E-Crew-Passwort-1234'); await crewContext.close(); const anonymousContext = await browser.newContext({ viewport: { width: 390, height: 844 } }); const loginPage = await anonymousContext.newPage(); await loginPage.goto('/login'); await setTheme(loginPage, 'dark'); await loginPage.reload(); await expectNoHorizontalOverflow(loginPage); await loginPage.screenshot({ path: testInfo.outputPath('login-mobile-dark.png'), fullPage: true }); await anonymousContext.close(); }); test('desktop fundus is a compact, filterable list with persistent themes', async ({ page }, testInfo) => { await page.setViewportSize({ width: 1440, height: 1000 }); await page.goto('/fundus'); await expect(page.getByRole('heading', { name: 'Fundus', exact: true })).toBeVisible(); await expect(page.locator('.sidebar')).toBeVisible(); await expect(page.locator('.mobile-navigation')).toBeHidden(); await expect(page.locator('.utility-navigation a')).toHaveText('Verwaltung'); await expect(page.locator('.piece-card')).toHaveCount(4); const firstRow = await page.locator('.piece-card').nth(0).boundingBox(); const secondRow = await page.locator('.piece-card').nth(1).boundingBox(); expect(firstRow?.x).toBe(secondRow?.x); expect(firstRow?.y).toBeLessThan(secondRow?.y ?? 0); expect(firstRow?.height ?? 999).toBeLessThan(130); const searchBox = await page.locator('.search-field').boundingBox(); const statusBox = await page.getByLabel('Status').boundingBox(); expect(searchBox?.height).toBe(statusBox?.height); await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('Pixelart'); await page.getByRole('button', { name: 'Anwenden' }).click(); await expect(page.locator('.piece-card')).toHaveCount(1); await page.getByLabel('Status').selectOption('preparing'); await page.getByRole('button', { name: 'Anwenden' }).click(); await expect(page.getByRole('heading', { name: 'Kein passendes Piece' })).toBeVisible(); await page.getByRole('link', { name: 'Filter zurücksetzen' }).click(); await expect(page.locator('.piece-card')).toHaveCount(4); await page.getByRole('button', { name: 'Darstellung: System' }).click(); await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light'); await page.reload(); await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light'); await expect(page.getByRole('button', { name: 'Darstellung: Hell' })).toBeVisible(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('fundus-desktop-light.png'), fullPage: true }); }); test('desktop builder keeps the fundus beside the wider run sheet', async ({ page }, testInfo) => { await page.setViewportSize({ width: 1280, height: 900 }); await page.goto('/fundus'); await setTheme(page, 'dark'); await page.goto('/sendungen'); await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); await expect(page.getByRole('heading', { name: 'Sendungen', exact: true })).toBeVisible(); await expect(page.getByText('Herbstsendung: Kleine Welten', { exact: true })).toBeVisible(); await expect(page.locator('.source-pane')).toBeVisible(); await expect(page.locator('.run-pane')).toBeVisible(); await expect(page.locator('.mobile-pane-switch')).toBeHidden(); await expect( page.locator('.show-summary strong').filter({ hasText: '1 Std. 25 Min.' }) ).toBeVisible(); const sourceBox = await page.locator('.source-pane').boundingBox(); const runBox = await page.locator('.run-pane').boundingBox(); expect(sourceBox?.x).toBeLessThan(runBox?.x ?? 0); expect(sourceBox?.width ?? 9999).toBeLessThan(runBox?.width ?? 0); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('sendungen-desktop-dark.png'), fullPage: true }); }); test('narrow desktop retains the sidebar and switches the builder panes', async ({ page }) => { await page.setViewportSize({ width: 960, height: 850 }); await page.goto('/sendungen'); await expect(page.locator('.sidebar')).toBeVisible(); await expect(page.locator('.mobile-navigation')).toBeHidden(); await expect(page.locator('.mobile-pane-switch')).toBeVisible(); await expect(page.locator('.run-pane')).toBeVisible(); await expect(page.locator('.source-pane')).toBeHidden(); await page.getByRole('button', { name: 'Fundus', exact: true }).click(); await expect(page.locator('.source-pane')).toBeVisible(); await expect(page.locator('.run-pane')).toBeHidden(); await expectNoHorizontalOverflow(page); }); test('mobile and tablet navigation do not obscure switchable content', async ({ page }, testInfo) => { for (const width of [390, 768]) { await page.setViewportSize({ width, height: 900 }); await page.goto('/sendungen'); await expect(page.locator('.sidebar')).toBeHidden(); await expect(page.locator('.mobile-navigation')).toBeVisible(); await expect(page.locator('.run-pane')).toBeVisible(); await page.getByRole('button', { name: 'Fundus', exact: true }).click(); await expect(page.locator('.source-pane')).toBeVisible(); await expect(page.locator('.run-pane')).toBeHidden(); await expectNoHorizontalOverflow(page); await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); const verticalSpace = await page.evaluate(() => { const navigation = document.querySelector('.mobile-navigation')?.getBoundingClientRect(); const content = document.querySelector('.source-pane')?.getBoundingClientRect(); return { contentBottom: content?.bottom ?? 0, navigationTop: navigation?.top ?? 0 }; }); expect(verticalSpace.contentBottom).toBeLessThanOrEqual(verticalSpace.navigationTop + 1); } await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/sendungen'); await page.screenshot({ path: testInfo.outputPath('sendungen-mobile.png'), fullPage: true }); }); test('all work areas fit at required widths in light and dark mode', async ({ page }, testInfo) => { for (const viewport of viewports) { await page.setViewportSize({ width: viewport.width, height: viewport.height }); await page.goto('/fundus'); await setTheme(page, viewport.theme); for (const route of routes) { await page.goto(route); await expect(page.locator('html')).toHaveAttribute('data-theme', viewport.theme); await expectNoHorizontalOverflow(page); } } await page.setViewportSize({ width: 768, height: 900 }); await page.goto('/fundus'); await setTheme(page, 'dark'); await page.goto('/verwaltung'); await page.screenshot({ path: testInfo.outputPath('verwaltung-tablet-dark.png'), fullPage: true }); }); test('long titles and longer fundus lists reflow without overflow', async ({ page }, testInfo) => { await page.setViewportSize({ width: 1280, height: 900 }); await page.goto('/fundus'); await expect(page.locator('.piece-card')).toHaveCount(4); await page.evaluate(() => { const list = document.querySelector('.piece-grid'); const rows = Array.from(document.querySelectorAll('.piece-card')); const title = rows[0]?.querySelector('h2'); if (title) { title.textContent = 'Ein außergewöhnlich langer redaktioneller Titel über kleine Konsolen, große Ideen und unerwartete Wendungen'; } for (let repetition = 0; repetition < 3; repetition += 1) { for (const row of rows) list?.append(row.cloneNode(true)); } }); await expect(page.locator('.piece-card')).toHaveCount(16); await expectNoHorizontalOverflow(page); await page.setViewportSize({ width: 390, height: 844 }); await expectNoHorizontalOverflow(page); await expect(page.locator('.piece-card').first()).toHaveCSS('flex-direction', 'column'); const mobileCard = await page.locator('.piece-card').first().boundingBox(); const mobilePrimary = await page.locator('.piece-primary').first().boundingBox(); expect(mobilePrimary?.width ?? 0).toBeGreaterThan((mobileCard?.width ?? 0) - 40); await expect(page.locator('.piece-card').first().getByRole('heading')).toContainText( 'Ein außergewöhnlich langer redaktioneller Titel' ); await page.screenshot({ path: testInfo.outputPath('fundus-mobile-long-content.png'), fullPage: true }); }); test('inbox, archive and administration stay concise', async ({ page }) => { for (const width of [390, 1280]) { await page.setViewportSize({ width, height: 900 }); await page.goto('/eingang'); await expect(page.locator('.capture-panel')).toBeVisible(); await expect(page.locator('.inbox-item')).toHaveCount(1); await expectNoHorizontalOverflow(page); await page.goto('/archiv'); await expect(page.locator('.archive-list article')).toHaveCount(1); await expectNoHorizontalOverflow(page); } await page.setViewportSize({ width: 1280, height: 900 }); await page.goto('/verwaltung'); const panels = page.locator('.taxonomy-panel'); await expect(panels).toHaveCount(3); const boxes = await Promise.all([ panels.nth(0).boundingBox(), panels.nth(1).boundingBox(), panels.nth(2).boundingBox() ]); expect(boxes[0]?.y).toBe(boxes[1]?.y); expect(boxes[1]?.y).toBe(boxes[2]?.y); }); test('live preview retains its existing readable controls', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await page.goto('/live'); await expect(page.getByText('Vorschau · Live-Modus', { exact: true })).toBeVisible(); await expect(page.getByText('Eine Kurbel, ein gelber Knopf und sehr viel Charme')).toBeVisible(); const before = await page .locator('.live-current') .evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size')); await page.getByRole('button', { name: 'Schrift vergrößern' }).click(); const after = await page .locator('.live-current') .evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size')); expect(after).not.toBe(before); await page.getByText('Recherche öffnen').click(); await expect(page.locator('.research-details')).toHaveAttribute('open', ''); await expectNoHorizontalOverflow(page); }); test('pieces autosave safely, detect conflicts and protect uploaded images', async ({ page, browser }, testInfo) => { await page.setViewportSize({ width: 1280, height: 1000 }); await page.goto('/fundus'); await page.getByText('Neues Piece anlegen').click(); await page.getByRole('button', { name: /Reaction \/ Fundstück/ }).click(); await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/); const pieceUrl = page.url(); await page.getByLabel('Titel').fill('E2E Fundstück mit Markdown'); await page.getByRole('link', { name: '← Fundus', exact: true }).click(); await expect(page).toHaveURL(/\/fundus$/); await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click(); await expect(page).toHaveURL(pieceUrl); await page .getByLabel('Kurzbeschreibung') .fill('Persistiert nach Neuladen und ist gezielt suchbar.'); await page.getByLabel('Status').selectOption('ready'); await page.getByLabel('Dauer in Sekunden').fill('615'); await page.getByLabel('Gaming').check(); await page.getByLabel('Private Recherche').fill('Nur intern: **Hintergrund prüfen**.'); await page .getByLabel('Präsentationstext') .fill('## Sichere Vorschau\n\n **sichtbar**'); await page.getByRole('button', { name: 'Quelle hinzufügen' }).click(); const source = page.locator('.source-row').last(); await source.getByLabel('Name').fill('Originalbeitrag'); await source.getByLabel('URL').fill('https://example.com/e2e-fundstueck'); await source.getByLabel('Zeitmarke in Sekunden').fill('42'); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await page.waitForTimeout(1_200); await expect(page.locator('.save-indicator')).toContainText('Gespeichert'); await expect(page.locator('.markdown-preview script')).toHaveCount(0); await expect(page.locator('.markdown-preview').last()).toContainText('Sichere Vorschau'); await page.reload(); await expect(page.getByLabel('Titel')).toHaveValue('E2E Fundstück mit Markdown'); await expect(page.getByLabel('Private Recherche')).toHaveValue( 'Nur intern: **Hintergrund prüfen**.' ); const png = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64' ); await page.getByLabel('Bild', { exact: true }).setInputFiles({ name: 'redaktionsbild.png', mimeType: 'image/png', buffer: png }); await page.getByLabel('Alternativtext').fill('Ein Testpixel'); await page.getByRole('button', { name: 'Bild hochladen' }).click(); const image = page.locator('.media-grid img'); await expect(image).toHaveCount(1); await expect(image).toHaveAttribute('alt', 'Ein Testpixel'); const mediaUrl = await image.getAttribute('src'); const anonymous = await browser.newContext(); const anonymousResponse = await anonymous.request.get(new URL(mediaUrl!, page.url()).toString(), { maxRedirects: 0 }); expect(anonymousResponse.status()).toBe(303); await anonymous.close(); const concurrent = await page.context().newPage(); await concurrent.goto(pieceUrl); await page.getByLabel('Titel').fill('E2E Fundstück, extern aktualisiert'); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await concurrent .getByLabel('Kurzbeschreibung') .fill('Diese veraltete Änderung darf nicht gewinnen.'); await expect(concurrent.locator('.save-indicator')).toContainText('neuere Änderung', { timeout: 10_000 }); await concurrent.reload(); await expect(concurrent.getByLabel('Titel')).toHaveValue('E2E Fundstück, extern aktualisiert'); await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue( 'Diese veraltete Änderung darf nicht gewinnen.' ); await concurrent.close(); await page.goto('/fundus?q=extern+aktualisiert'); await expect(page.locator('.piece-card')).toHaveCount(1); await expect( page.getByRole('heading', { name: 'E2E Fundstück, extern aktualisiert' }) ).toBeVisible(); await page.getByRole('link', { name: 'Bearbeiten' }).click(); await page.getByRole('button', { name: 'Piece archivieren' }).click(); await expect(page).toHaveURL(/\/archiv$/); const archived = page.locator('.archive-list article').filter({ hasText: 'extern aktualisiert' }); await expect(archived).toBeVisible(); await archived.getByRole('button', { name: 'Wiederherstellen' }).click(); await expect(archived).toHaveCount(0); await page.goto('/fundus?q=extern+aktualisiert'); await expect(page.locator('.piece-card')).toHaveCount(1); await page.locator('.piece-card').getByRole('button', { name: 'Duplizieren' }).click(); await expect(page.getByLabel('Titel')).toHaveValue( 'Kopie von E2E Fundstück, extern aktualisiert' ); await page.setViewportSize({ width: 390, height: 844 }); await setTheme(page, 'dark'); await page.reload(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('piece-editor-mobile-dark.png'), fullPage: true }); });