import { expect, test, type Locator, type Page } from '@playwright/test'; import Database from 'better-sqlite3'; 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 openEditorialField(page: Page, heading: string) { if (heading.includes('Recherche')) { await page.getByRole('button', { name: 'Recherche & Quellen' }).click(); } else if (heading.includes('Präsentation')) { await page.getByRole('button', { name: 'Präsentation', exact: true }).click(); } else { await page.getByRole('button', { name: 'Sendungstext', exact: true }).click(); } } async function editInlineTitle(scope: Page | Locator, title: string, value: string) { await scope.getByRole('button', { name: `${title} Titel bearbeiten` }).click(); await scope.getByLabel('Titel', { exact: true }).fill(value); await scope.getByLabel('Titel', { exact: true }).press('Enter'); } 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$/); } async function openSeededShow(page: Page) { await page.goto('/sendungen'); await page.getByRole('link', { name: /Herbstsendung: Kleine Welten/ }).click(); await expect(page).toHaveURL(/\/sendungen\/show-october$/); } 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]; } function addSentHistoryFixture(pieceId: string) { const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR; if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.'); const database = new Database(path.join(dataDirectory, 'e2e.sqlite')); database.pragma('foreign_keys = ON'); const insertShow = database.prepare( `INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status, revision, created_at_utc, updated_at_utc) VALUES (?, ?, ?, '', '', 'completed', 1, ?, ?)` ); const insertSection = database.prepare( `INSERT INTO show_sections (id, show_id, name, category_id, position, explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, 'Ablauf', NULL, 0, NULL, 0)` ); const insertUsage = database.prepare( `INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status, duration_seconds, title_snapshot, intro_snapshot, research_snapshot, moderation_snapshot, transition_snapshot, presentation_snapshot, own_notes, is_reserve) VALUES (?, ?, ?, 0, 'done', 300, 'Historische Verwendung', '', '', '', '', '', '', 0)` ); database.transaction(() => { for (const [suffix, title, date] of [ ['older', 'Rückblick im September', '2026-09-10T18:00:00.000Z'], ['latest', 'Rückblick im Oktober', '2026-10-10T18:00:00.000Z'] ] as const) { const showId = `e2e-history-${suffix}`; const sectionId = `e2e-history-section-${suffix}`; insertShow.run(showId, title, date, date, date); insertSection.run(sectionId, showId); insertUsage.run(`e2e-history-usage-${suffix}`, sectionId, pieceId); } })(); database.close(); } function removeSentHistoryFixture() { const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR; if (!dataDirectory) return; const database = new Database(path.join(dataDirectory, 'e2e.sqlite')); database.pragma('foreign_keys = ON'); database.prepare("DELETE FROM shows WHERE id LIKE 'e2e-history-%'").run(); database.close(); } function addPresentationFixture() { const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR; if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.'); const database = new Database(path.join(dataDirectory, 'e2e.sqlite')); database.pragma('foreign_keys = ON'); const now = '2026-10-22T19:00:00.000Z'; database.transaction(() => { database .prepare( `INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status, revision, created_at_utc, updated_at_utc) VALUES ('e2e-presentation', 'E2E Präsentation', ?, '', '', 'ready', 1, ?, ?)` ) .run(now, now, now); database .prepare( `INSERT INTO show_sections (id, show_id, name, category_id, position, explicit_duration_seconds, is_reserve_bank) VALUES ('e2e-presentation-section', 'e2e-presentation', 'Player', NULL, 0, NULL, 0), ('e2e-presentation-reserve', 'e2e-presentation', 'Reservebank', NULL, 1, NULL, 1)` ) .run(); const insert = database.prepare( `INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status, duration_seconds, title_snapshot, intro_snapshot, research_snapshot, moderation_snapshot, transition_snapshot, presentation_snapshot, own_notes, is_reserve, presentation_mode_snapshot, presentation_source_label_snapshot, presentation_source_url_snapshot, presentation_source_timecode_snapshot, presentation_title_meta_snapshot, presentation_description_snapshot, presentation_image_url_snapshot, presentation_site_name_snapshot, presentation_provider_snapshot, presentation_provider_id_snapshot) VALUES (?, 'e2e-presentation-section', NULL, ?, 'open', 300, ?, '', ?, ?, '', '', '', 0, 'video', ?, ?, ?, ?, '', '', ?, ?, ?)` ); insert.run( 'e2e-youtube', 0, 'YouTube Kurzlink', 'PRIVATE_RESEARCH_YOUTUBE', 'PRIVATE_MODERATION_YOUTUBE', 'YouTube', 'https://youtu.be/dQw4w9WgXcQ', 75, 'YouTube Vorschau', 'YouTube', 'youtube', 'dQw4w9WgXcQ' ); insert.run( 'e2e-tiktok', 1, 'TikTok Hochformat', 'PRIVATE_RESEARCH_TIKTOK', 'PRIVATE_MODERATION_TIKTOK', 'TikTok', 'https://www.tiktok.com/@scout2015/video/6718335390845095173', 12, 'TikTok Vorschau', 'TikTok', 'tiktok', '6718335390845095173' ); })(); database.close(); } function removePresentationFixture() { const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR; if (!dataDirectory) return; const database = new Database(path.join(dataDirectory, 'e2e.sqlite')); database.pragma('foreign_keys = ON'); database.prepare("DELETE FROM shows WHERE id = 'e2e-presentation'").run(); database.close(); } function addCalendarFixture() { const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR; if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.'); const database = new Database(path.join(dataDirectory, 'e2e.sqlite')); const insert = database.prepare( `INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status, revision, created_at_utc, updated_at_utc) VALUES (?, ?, ?, '', '', 'draft', 1, ?, ?)` ); const now = '2026-10-24T10:00:00.000Z'; database.transaction(() => { insert.run('e2e-calendar-late', 'Kalender Spättermin', '2026-10-24T18:00:00.000Z', now, now); insert.run('e2e-calendar-early', 'Kalender Frühtermin', '2026-10-24T08:00:00.000Z', now, now); insert.run( 'e2e-calendar-next', 'Kalender Novembertermin', '2026-11-03T17:00:00.000Z', now, now ); insert.run('e2e-calendar-undated', 'Kalender ohne Termin', null, now, now); })(); database.close(); } function removeCalendarFixture() { const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR; if (!dataDirectory) return; const database = new Database(path.join(dataDirectory, 'e2e.sqlite')); database.prepare("DELETE FROM shows WHERE id LIKE 'e2e-calendar-%'").run(); database.close(); } function setSectionDuration(sectionId: string, seconds: number | null) { const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR; if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.'); const database = new Database(path.join(dataDirectory, 'e2e.sqlite')); database .prepare('UPDATE show_sections SET explicit_duration_seconds = ? WHERE id = ?') .run(seconds, sectionId); database.close(); } 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(1); await page .locator('.filter-picker') .evaluate((element) => ((element as HTMLDetailsElement).open = true)); await page.getByLabel('Auch eingeplante Inhalte zeigen').check(); await expect(page.locator('.piece-card')).toHaveCount(4); await page .getByRole('button', { name: 'Die kleine Konsole mit dem großen Dreh als Favorit markieren' }) .click(); await expect( page.getByRole('button', { name: 'Die kleine Konsole mit dem großen Dreh aus Favoriten entfernen' }) ).toBeVisible(); await expect(page.locator('.piece-card').first()).toContainText( 'Die kleine Konsole mit dem großen Dreh' ); await page .locator('.filter-picker') .evaluate((element) => ((element as HTMLDetailsElement).open = true)); 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: 'Filter anwenden' }).click(); await expect(page.locator('.piece-card')).toHaveCount(1); await page .locator('.filter-picker') .evaluate((element) => ((element as HTMLDetailsElement).open = true)); await page.getByLabel('Status').selectOption('preparing'); await page.getByRole('button', { name: 'Filter anwenden' }).click(); await expect(page.getByRole('heading', { name: 'Kein passendes Piece' })).toBeVisible(); await page .locator('.filter-picker') .evaluate((element) => ((element as HTMLDetailsElement).open = true)); await page.getByRole('button', { name: 'Zurücksetzen' }).click(); await expect(page.locator('.piece-card')).toHaveCount(1); 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('sent and planned source filters stay independent and removing the last usage offers the piece again', async ({ page }, testInfo) => { addSentHistoryFixture('piece-effects'); try { await page.goto('/fundus'); await expect( page.getByRole('heading', { name: 'Warum praktische Effekte wieder auffallen' }) ).toHaveCount(0); await page.evaluate(() => (document.body.dataset.filterDocument = 'stable')); await page .locator('.filter-picker') .evaluate((element) => ((element as HTMLDetailsElement).open = true)); await page.getByLabel('Auch verwendete Inhalte zeigen').check(); await page .locator('.filter-picker') .evaluate((element) => ((element as HTMLDetailsElement).open = true)); await expect( page.getByRole('heading', { name: 'Warum praktische Effekte wieder auffallen' }) ).toHaveCount(0); await page.getByLabel('Auch eingeplante Inhalte zeigen').check(); const usedPiece = page .locator('.piece-card') .filter({ hasText: 'Warum praktische Effekte wieder auffallen' }); await expect(usedPiece).toBeVisible(); await expect(usedPiece).toContainText('Gesendet am 10.10.2026'); await expect(usedPiece).toContainText('+ 1 weitere'); await expect(usedPiece.getByRole('link', { name: 'Rückblick im Oktober' })).toHaveAttribute( 'href', '/sendungen/e2e-history-latest' ); await usedPiece.getByText('Vollständige Verwendungshistorie').click(); await expect(usedPiece.locator('.piece-usage-history li')).toHaveCount(2); await expect(page.locator('body')).toHaveAttribute('data-filter-document', 'stable'); await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('praktische Effekte'); await page.getByRole('button', { name: 'Filter anwenden' }).click(); await expect(page.getByLabel('Auch verwendete Inhalte zeigen')).toBeChecked(); await expect(usedPiece).toBeVisible(); await page.screenshot({ path: testInfo.outputPath('fundus-verwendungshistorie-desktop.png'), fullPage: true }); await page.goto('/sendungen'); await page.getByText('Neue Sendung planen', { exact: true }).click(); await page.getByLabel('Titel', { exact: true }).fill('E2E Filterprüfung'); await page.getByRole('radio', { name: /Standardablauf/ }).check(); await page.getByRole('button', { name: 'Sendung anlegen' }).click(); await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole'); await page.getByRole('button', { name: 'Filtern' }).click(); const consolePiece = page .locator('.builder-source-card') .filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' }); await consolePiece.getByRole('button', { name: 'Hinzufügen' }).click(); await expect(consolePiece).toHaveCount(0); await page.getByPlaceholder('Fundus durchsuchen').fill(''); await page.getByRole('button', { name: 'Filtern' }).click(); await page.getByLabel('Auch eingeplante Inhalte zeigen').check(); await expect( page .locator('.builder-source-card') .filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' }) ).toContainText('Eingeplant'); await expect( page .locator('.builder-source-card') .filter({ hasText: 'Warum praktische Effekte wieder auffallen' }) ).toHaveCount(0); await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole'); await page.getByRole('button', { name: 'Filtern' }).click(); await expect(page.getByLabel('Auch eingeplante Inhalte zeigen')).toBeChecked(); await expect( page .locator('.builder-source-card') .filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' }) ).toContainText('Eingeplant'); await page.getByPlaceholder('Fundus durchsuchen').fill(''); await page.getByRole('button', { name: 'Filtern' }).click(); await page.getByLabel('Auch eingeplante Inhalte zeigen').uncheck(); await page.getByLabel('Auch verwendete Inhalte zeigen').check(); await expect( page .locator('.builder-source-card') .filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' }) ).toHaveCount(0); await expect( page .locator('.builder-source-card') .filter({ hasText: 'Warum praktische Effekte wieder auffallen' }) ).toContainText('Rückblick im Oktober'); await page.screenshot({ path: testInfo.outputPath('sendungsfilter-desktop.png'), fullPage: true }); page.once('dialog', (dialog) => dialog.accept()); await page .locator('.reserve-usage') .getByRole('button', { name: /aus Sendung entfernen/ }) .click(); await page.getByLabel('Auch verwendete Inhalte zeigen').uncheck(); await expect( page .locator('.builder-source-card') .filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' }) ).toBeVisible(); await page.setViewportSize({ width: 390, height: 844 }); await page.getByRole('button', { name: 'Fundus', exact: true }).click(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('sendungsfilter-mobile.png'), fullPage: true }); } finally { removeSentHistoryFixture(); } }); test('show overview switches persistently between list, calendar and mobile agenda', async ({ page }, testInfo) => { addCalendarFixture(); try { await page.setViewportSize({ width: 1440, height: 1000 }); await page.goto('/sendungen?month=2026-10'); await page.getByRole('button', { name: 'Kalender' }).click(); await expect(page.getByRole('heading', { name: 'Oktober 2026' })).toBeVisible(); const octoberDay = page .locator('.show-calendar-day') .filter({ hasText: 'Kalender Frühtermin' }); await expect(octoberDay).toHaveClass(/today/); await expect(octoberDay).toContainText('Kalender Spättermin'); await expect(octoberDay.locator('.show-calendar-event')).toHaveCount(2); await expect(octoberDay.locator('.show-calendar-event').first()).toContainText('10:00'); await expect(page.locator('.show-undated')).toContainText('Kalender ohne Termin'); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('sendungen-kalender-desktop.png'), fullPage: true }); await page.getByRole('link', { name: 'Nächster Monat' }).click(); await expect(page.getByRole('heading', { name: 'November 2026' })).toBeVisible(); await expect(page.getByRole('link', { name: 'Kalender Novembertermin' })).toBeVisible(); await page.reload(); await expect(page.getByRole('button', { name: 'Kalender' })).toHaveAttribute( 'aria-pressed', 'true' ); await page.setViewportSize({ width: 390, height: 844 }); await page.getByRole('link', { name: 'Vorheriger Monat' }).click(); await expect(page.locator('.show-calendar')).toBeHidden(); await expect(page.locator('.show-agenda')).toContainText('Kalender Frühtermin'); await expect(page.locator('.show-agenda')).toContainText('Kalender Spättermin'); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('sendungen-kalender-mobile.png'), fullPage: true }); await page.getByRole('button', { name: 'Liste' }).click(); await expect(page.getByRole('heading', { name: 'Geplante Sendungen' })).toBeVisible(); } finally { removeCalendarFixture(); } }); 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 openSeededShow(page); await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); await expect(page.getByRole('heading', { name: 'Herbstsendung: Kleine Welten' })).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 openSeededShow(page); 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 openSeededShow(page); 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 openSeededShow(page); await page.screenshot({ path: testInfo.outputPath('sendungen-mobile.png'), fullPage: true }); }); test('a show can be planned from a template with independent usages and reserve', async ({ page }, testInfo) => { test.slow(); await page.setViewportSize({ width: 1440, height: 1000 }); await page.goto('/sendungen'); await page.getByText('Neue Sendung planen', { exact: true }).click(); await page.getByLabel('Titel', { exact: true }).fill('E2E Planung Spiele und JC'); await page.getByRole('radio', { name: /Standardablauf/ }).check(); await page.getByRole('button', { name: 'Sendung anlegen' }).click(); await expect(page).toHaveURL(/\/sendungen\/[a-f0-9-]+$/); const showUrl = page.url(); await expect(page.locator('.run-section')).toHaveCount(5); const firstSection = page.locator('.run-section').first(); const sectionTransfer = await page.evaluateHandle(() => new DataTransfer()); await firstSection.locator('.section-drag-handle').dispatchEvent('dragstart', { dataTransfer: sectionTransfer }); await page.locator('.run-section').nth(1).dispatchEvent('dragover', { dataTransfer: sectionTransfer }); await expect(page.locator('[data-drop-preview="section"]')).toContainText('Start'); await expect(firstSection).toHaveClass(/is-dragging/); await firstSection.locator('.section-drag-handle').dispatchEvent('dragend', { dataTransfer: sectionTransfer }); await sectionTransfer.dispose(); await expect(page.locator('[data-drop-preview="section"]')).toHaveCount(0); await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole'); await page.getByRole('button', { name: 'Filtern' }).click(); const source = page .locator('.builder-source-card') .filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' }); await expect(page.locator('.builder-source-card')).toHaveCount(1); const reactionSection = page .locator('.run-section') .filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) }); await page.locator('body').evaluate((body) => (body.dataset.builderDocument = 'stable')); const sourceDragHandle = source.locator('.source-drag-copy'); const emptyReactionTarget = reactionSection.locator('.section-placeholder'); const dataTransfer = await page.evaluateHandle(() => new DataTransfer()); await sourceDragHandle.dispatchEvent('dragstart', { dataTransfer }); await emptyReactionTarget.dispatchEvent('dragover', { dataTransfer }); const insertionPreview = reactionSection.locator('[data-drop-preview="usage"]'); await expect(insertionPreview).toContainText('Die kleine Konsole mit dem großen Dreh'); await expect(source).toHaveClass(/is-dragging/); await expect(source).toHaveCSS('opacity', '0.45'); await emptyReactionTarget.dispatchEvent('drop', { dataTransfer }); await dataTransfer.dispose(); await expect(reactionSection.locator('.editable-usage')).toHaveCount(1); await expect(page.locator('body')).toHaveAttribute('data-builder-document', 'stable'); await expect(source).toHaveCount(0); await page.getByLabel('Auch eingeplante Inhalte zeigen').check(); await expect(source).toContainText('Eingeplant'); await sourceDragHandle.dragTo(reactionSection.locator('.editable-usage')); let usages = page .locator('.run-section') .filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) }) .locator('.editable-usage'); await expect(usages).toHaveCount(2); await usages .nth(0) .getByRole('button', { name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben' }) .click(); await expect(page.locator('dialog[open]')).toContainText('Sendungsfassung'); await expect(page.locator('dialog[open] .piece-dialog-workspace')).toBeVisible(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('verwendungseditor-desktop.png'), fullPage: true }); await page .locator('dialog[open]') .getByRole('button', { name: 'Aus Fundus aktualisieren' }) .click(); await expect(page.locator('dialog[open]')).toHaveCount(0); usages = page .locator('.run-section') .filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) }) .locator('.editable-usage'); await usages .nth(0) .getByRole('button', { name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben' }) .click(); await editInlineTitle( usages.nth(0), 'Die kleine Konsole mit dem großen Dreh', 'Konsole als Auftakt' ); await usages.nth(0).locator('.piece-meta-details > summary').click(); await usages.nth(0).getByLabel('Dauer in Minuten').fill('5'); await usages.nth(0).getByRole('button', { name: 'Recherche & Quellen' }).click(); await usages .nth(0) .getByRole('textbox', { name: 'Interne Notizen zu dieser Verwendung', exact: true }) .fill('Kurbel zuerst zeigen'); await usages.nth(0).getByRole('button', { name: 'Verwendung speichern' }).click(); await expect(reactionSection).toContainText('Konsole als Auftakt'); usages = page .locator('.run-section') .filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) }) .locator('.editable-usage'); await usages .nth(1) .getByRole('button', { name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben' }) .click(); await editInlineTitle( usages.nth(1), 'Die kleine Konsole mit dem großen Dreh', 'Konsole als Rückblick' ); await usages.nth(1).locator('.piece-meta-details > summary').click(); await usages.nth(1).getByLabel('Dauer in Minuten').fill('15'); await usages.nth(1).getByRole('button', { name: 'Recherche & Quellen' }).click(); await usages .nth(1) .getByRole('textbox', { name: 'Interne Notizen zu dieser Verwendung', exact: true }) .fill('Andere Perspektive'); await usages.nth(1).getByRole('button', { name: 'Verwendung speichern' }).click(); await expect(page.locator('.show-summary')).toContainText('2 Std. 25 Min.'); usages = page .locator('.run-section') .filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) }) .locator('.editable-usage'); await usages .nth(1) .getByRole('button', { name: 'Konsole als Rückblick bearbeiten und verschieben' }) .click(); await usages.nth(1).getByRole('button', { name: 'Duplizieren' }).click(); await expect(reactionSection.locator('.editable-usage')).toHaveCount(3); await expect(reactionSection.locator('.editable-usage').nth(2)).toContainText( 'Konsole als Rückblick' ); await expect(page.locator('.show-summary')).toContainText('2 Std. 40 Min.'); const planTimeBeforeReserve = await page.locator('.show-summary').textContent(); await source.getByLabel(/Ziel für/).selectOption(''); await source.getByRole('button', { name: 'Hinzufügen' }).click(); await expect(page.locator('.reserve-usage')).toHaveCount(1); expect(await page.locator('.show-summary').textContent()).toBe(planTimeBeforeReserve); await page .locator('.reserve-usage') .getByRole('button', { name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben' }) .click(); await expect(page.locator('dialog[open]')).toContainText('Sendungsfassung'); await expect( page.locator('dialog[open]').getByRole('button', { name: 'Duplizieren' }) ).toBeVisible(); await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click(); const moveUp = page.getByRole('button', { name: 'Konsole als Rückblick nach oben' }).first(); await moveUp.focus(); await page.keyboard.press('Enter'); await expect( page .locator('.run-section') .filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) }) .locator('.editable-usage strong') .first() ).toHaveText('Konsole als Rückblick'); const reserveUsage = page.locator('.reserve-usage'); const reserveHandle = reserveUsage.locator('.usage-drag-copy'); const finalSectionTarget = page.locator('.run-section').last().locator('.section-placeholder'); const reserveTransfer = await page.evaluateHandle(() => new DataTransfer()); await reserveHandle.dispatchEvent('dragstart', { dataTransfer: reserveTransfer }); await finalSectionTarget.dispatchEvent('dragover', { dataTransfer: reserveTransfer }); await expect(page.locator('[data-drop-preview="usage"]')).toContainText( 'Die kleine Konsole mit dem großen Dreh' ); await expect(reserveUsage).toHaveClass(/is-dragging/); await finalSectionTarget.dispatchEvent('drop', { dataTransfer: reserveTransfer }); await reserveTransfer.dispose(); await expect(page.locator('.reserve-usage')).toHaveCount(0); await expect(page.locator('body')).toHaveAttribute('data-builder-document', 'stable'); await page.reload(); await expect(page.locator('.editable-usage')).toHaveCount(4); const savedUsage = page.locator('.editable-usage').filter({ hasText: 'Konsole als Auftakt' }); await savedUsage .getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' }) .click(); await expect(savedUsage.locator('textarea[name="ownNotes"]')).toHaveValue('Kurbel zuerst zeigen'); await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click(); const duplicatedUsage = page .locator('.editable-usage') .filter({ hasText: 'Konsole als Rückblick' }); await expect(duplicatedUsage).toHaveCount(2); await duplicatedUsage .nth(1) .getByRole('button', { name: 'Konsole als Rückblick bearbeiten und verschieben' }) .click(); await expect(duplicatedUsage.nth(1).getByLabel('Dauer in Minuten')).toHaveValue('15'); await expect(duplicatedUsage.nth(1).locator('textarea[name="ownNotes"]')).toHaveValue( 'Andere Perspektive' ); await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click(); await page.setViewportSize({ width: 390, height: 844 }); await savedUsage .getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' }) .click(); await expect(page.locator('dialog[open] .piece-dialog-workspace')).toBeVisible(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('verwendungseditor-mobile.png'), fullPage: true }); await page .locator('dialog[open]') .getByRole('button', { name: 'Aus Sendung entfernen' }) .scrollIntoViewIfNeeded(); await expect( page.locator('dialog[open]').getByRole('button', { name: 'Aus Sendung entfernen' }) ).toBeVisible(); await page .locator('dialog[open] form[action="?/updateUsage"] input[name="revision"]') .evaluate((element) => ((element as HTMLInputElement).value = '1')); await page.locator('dialog[open]').getByRole('button', { name: 'Verwendung speichern' }).click(); await expect(page.getByRole('alert')).toContainText('inzwischen geändert'); await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click(); await page.getByRole('button', { name: 'Ablauf', exact: true }).click(); await page.getByPlaceholder('Neuer Abschnitt').fill('Finales Spiel'); await page.getByRole('button', { name: 'Abschnitt hinzufügen' }).click(); await expect(page.getByRole('heading', { name: 'Finales Spiel' })).toBeVisible(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('sendungsbau-mobile.png'), fullPage: true }); await page.goto('/fundus?q=kleine+Konsole&includePlanned=1'); await expect( page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }) ).toBeVisible(); await page.goto(showUrl); }); 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(1); 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(4); 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); const topicIconPicker = page.locator('.topic-management-panel .taxonomy-form .icon-picker'); for (const width of [1280, 960, 390]) { await page.setViewportSize({ width, height: 900 }); await topicIconPicker.getByText('Icon auswählen', { exact: true }).click(); const popover = topicIconPicker.locator('.icon-picker-popover'); await expect(popover).toBeVisible(); const popoverBox = await popover.boundingBox(); expect(popoverBox?.x ?? -1).toBeGreaterThanOrEqual(0); expect((popoverBox?.x ?? width) + (popoverBox?.width ?? 1)).toBeLessThanOrEqual(width); await topicIconPicker.getByText('Icon auswählen', { exact: true }).click(); } await page.setViewportSize({ width: 1280, height: 900 }); await topicIconPicker.getByText('Icon auswählen', { exact: true }).click(); await topicIconPicker.getByPlaceholder('Icon suchen').fill('Satellite Dish'); await topicIconPicker.getByRole('button', { name: 'Satellite Dish', exact: true }).click(); await expect(topicIconPicker.locator('input[type="hidden"]')).toHaveValue('satellite-dish'); }); test('tools stay editable in pieces after catalog deactivation', async ({ page }, testInfo) => { await page.goto('/verwaltung'); for (const width of [390, 1280]) { await page.setViewportSize({ width, height: 900 }); await expect(page.locator('.tool-editor-dialog')).toBeHidden(); await expectNoHorizontalOverflow(page); } await page.getByRole('button', { name: 'Werkzeug hinzufügen' }).click(); const create = page.getByRole('dialog', { name: 'Neues Werkzeug' }); await create.getByLabel('Name').fill('E2E Tierlist'); await create.getByLabel('Start-URL').fill('https://example.com/tierlist'); await create.getByLabel('Beschreibung').fill('Ranking für die Sendung'); await create.getByText('Icon auswählen', { exact: true }).click(); await create.getByPlaceholder('Icon suchen').fill('Satellite Dish'); await page.screenshot({ path: testInfo.outputPath('tool-icon-picker-desktop.png') }); await create.getByRole('button', { name: 'Satellite Dish', exact: true }).click(); await create.getByText('Formate auswählen', { exact: true }).click(); await create.getByRole('checkbox', { name: 'Quiz' }).check(); await create.getByRole('button', { name: 'Auswahl schließen' }).click(); await page.screenshot({ path: testInfo.outputPath('tool-create-desktop.png'), fullPage: true }); await create.getByRole('button', { name: 'Werkzeug anlegen' }).click(); const tool = page.locator('.tool-settings-list > button').filter({ hasText: 'E2E Tierlist' }); await expect(tool).toContainText('Aktiv'); await expect(tool.locator('.catalog-entry-icon svg')).toBeVisible(); await page.screenshot({ path: testInfo.outputPath('tool-catalog-desktop.png'), fullPage: true }); await page.goto('/fundus/piece-quiz'); await page.getByRole('button', { name: 'Recherche & Quellen' }).click(); await page.getByText('Werkzeuge auswählen', { exact: true }).click(); const toolChoice = page.getByRole('checkbox', { name: /E2E Tierlist/ }); await expect(toolChoice.locator('xpath=..').locator('svg')).toBeVisible(); await toolChoice.check(); await page.getByLabel('Arbeitslink').fill('https://example.com/tierlist/e2e-board'); await page.getByLabel('Notiz').fill('Vor der Sendung sortieren'); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); const toolsSection = page.locator('.piece-source-editor').filter({ hasText: 'Werkzeuge' }); await toolsSection.scrollIntoViewIfNeeded(); await page.screenshot({ path: testInfo.outputPath('piece-tools-desktop.png') }); await page.goto('/verwaltung'); const storedTool = page .locator('.tool-settings-list > button') .filter({ hasText: 'E2E Tierlist' }); await storedTool.click(); const edit = page.getByRole('dialog', { name: 'E2E Tierlist' }); await edit.getByRole('checkbox', { name: /^Aktiv/ }).uncheck(); await page.setViewportSize({ width: 390, height: 844 }); await expectNoHorizontalOverflow(page); await expect(edit.getByRole('button', { name: 'Änderungen speichern' })).toBeVisible(); await edit.getByText('Icon auswählen', { exact: true }).click(); await edit.getByPlaceholder('Icon suchen').fill('Satellite Dish'); await page.screenshot({ path: testInfo.outputPath('tool-icon-picker-mobile.png') }); await edit.getByRole('button', { name: 'Satellite Dish', exact: true }).click(); await page.screenshot({ path: testInfo.outputPath('tool-edit-mobile.png') }); await edit.getByRole('button', { name: 'Änderungen speichern' }).click(); await expect(storedTool).toContainText('Deaktiviert'); await page.screenshot({ path: testInfo.outputPath('tool-catalog-mobile.png'), fullPage: true }); await page.goto('/fundus/piece-quiz'); await page.getByRole('button', { name: 'Recherche & Quellen' }).click(); await expect(page.locator('.tool-assignment-row')).toContainText('E2E Tierlist'); await expect(page.locator('.tool-assignment-row')).toContainText('Deaktiviert'); await expect(page.getByLabel('Arbeitslink')).toHaveValue( 'https://example.com/tierlist/e2e-board' ); await page.setViewportSize({ width: 390, height: 844 }); await page .locator('.piece-source-editor') .filter({ hasText: 'Werkzeuge' }) .scrollIntoViewIfNeeded(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('piece-tools-mobile.png') }); }); test('validated YouTube and TikTok players switch without exposing private fields', async ({ page }, testInfo) => { addPresentationFixture(); try { await page.context().route('https://www.youtube-nocookie.com/**', (route) => route.fulfill({ contentType: 'text/html', body: '