import { expect, test, type Locator, type Page } from '@playwright/test'; import Database from 'better-sqlite3'; import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } 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) { const pieceNavigation = page.getByRole('navigation', { name: 'Arbeitsbereich' }); if (await pieceNavigation.count()) { if (heading.includes('Recherche')) { await pieceNavigation.getByRole('button', { name: 'Recherche & Quellen' }).click(); } else if (heading.includes('Präsentation')) { const presentation = page.locator('.piece-presentation-panel'); if (!(await presentation.isVisible())) { await pieceNavigation.getByRole('button', { name: 'Details & Ausgabe' }).click(); } } else { await pieceNavigation.getByRole('button', { name: 'Streamtext', exact: true }).click(); } return; } if (!(await page.getByRole('button', { name: 'Streamtext', exact: true }).count())) return; 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: 'Streamtext', 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 fillMarkdownSource(page: Page, label: string, value: string) { const fieldNames: Record = { 'Private Recherche': 'researchText', 'Öffentlicher Vorspann': 'presentationText' }; const field = page.locator(`.rich-markdown-field:has(input[name="${fieldNames[label]}"])`); if (!(await field.locator('textarea:visible').count())) { await field.getByRole('textbox', { name: label, exact: true }).click(); await field.getByRole('button', { name: 'Markdown' }).click(); } await field.locator('textarea').fill(value); } async function openPieceMoreDetails(page: Page) { const details = page.locator('.piece-more-details'); if ((await details.count()) && !(await details.getAttribute('open'))) { await details.locator('summary').click(); } } 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 .locator('.show-card-list') .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, completed_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, date); insertSection.run(sectionId, showId); const usageId = `e2e-history-usage-${suffix}`; insertUsage.run(usageId, sectionId, pieceId); database .prepare( `INSERT INTO show_finalizations (show_id, command_id, source_revision, finalized_at_utc) VALUES (?, ?, 1, ?)` ) .run(showId, `fixture-${suffix}`, date); database .prepare( `INSERT INTO piece_usage_history (usage_id, show_id, original_piece_id, show_title_snapshot, usage_title_snapshot, used_at_utc, actual_duration_seconds) VALUES (?, ?, ?, ?, 'Historische Verwendung', ?, 300)` ) .run(usageId, showId, pieceId, title, date); } })(); 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 piece_usage_history WHERE show_id LIKE 'e2e-history-%'").run(); database.prepare("DELETE FROM shows WHERE id LIKE 'e2e-history-%'").run(); database.close(); } function addCompletionFixture() { 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-25T19:00:00.000Z'; const png = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64' ); mkdirSync(path.join(dataDirectory, 'uploads'), { recursive: true }); writeFileSync(path.join(dataDirectory, 'uploads', 'e2e-share.png'), png); 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-completion', 'E2E Abschluss', ?, '', '', 'live', 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-completion-section', 'e2e-completion', 'Finaler Ablauf', NULL, 0, NULL, 0), ('e2e-completion-reserve', 'e2e-completion', 'Reservebank', NULL, 1, NULL, 1)` ) .run(); 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, interaction_snapshot, transition_snapshot, presentation_snapshot, own_notes, is_reserve) VALUES (?, 'e2e-completion-section', ?, ?, ?, 300, ?, ?, ?, ?, ?, ?, '', ?, 0)` ); insertUsage.run( 'e2e-completion-done', 'piece-console', 0, 'done', 'Gesendeter Abschlussbeitrag', 'FREIGABE_EINLEITUNG', 'PRIVATE_FREIGABE_RECHERCHE', 'FREIGABE_MODERATION', 'FREIGABE_INTERAKTION', 'FREIGABE_UEBERLEITUNG', 'PRIVATE_FREIGABE_NOTIZ' ); insertUsage.run( 'e2e-completion-skipped', 'piece-effects', 1, 'skipped', 'Übersprungener Abschlussbeitrag', '', 'PRIVATE_SKIPPED_RESEARCH', '', '', '', '' ); database .prepare( `INSERT INTO show_live_states (show_id, revision, started_at_utc, ended_at_utc, presentation_revision, updated_at_utc) VALUES ('e2e-completion', 0, ?, ?, 0, ?)` ) .run('2026-10-25T18:00:00.000Z', now, now); database .prepare( `INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type, byte_size, alt_text, position, created_at_utc) VALUES ('e2e-share-media', 'piece-console', 'e2e-share.png', 'intern.png', 'image/png', ?, 'Freigegebenes Abschlussbild', 77, ?)` ) .run(png.byteLength, now); database .prepare( `INSERT INTO usage_media_snapshots (id, usage_id, original_media_id, alt_text, position, is_presentation) VALUES ('e2e-share-snapshot', 'e2e-completion-done', 'e2e-share-media', 'Freigegebenes Abschlussbild', 0, 1)` ) .run(); })(); database.close(); } function finalizeCompletionFixture() { 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 now = '2026-10-25T19:00:00.000Z'; database.transaction(() => { const actor = database.prepare('SELECT id FROM users WHERE email = ?').get(adminEmail) as { id: string; }; database .prepare( `INSERT INTO piece_usage_history (usage_id, show_id, original_piece_id, show_title_snapshot, usage_title_snapshot, used_at_utc, actual_duration_seconds) VALUES ('e2e-completion-done', 'e2e-completion', 'piece-console', 'E2E Abschluss', 'Gesendeter Abschlussbeitrag', ?, 300)` ) .run(now); database .prepare( `INSERT INTO show_finalizations (show_id, command_id, source_revision, finalized_by_user_id, finalized_at_utc) VALUES ('e2e-completion', 'e2e-fixture-finalization', 1, ?, ?)` ) .run(actor.id, now); database .prepare( `UPDATE shows SET status = 'completed', completed_at_utc = ?, revision = 2, updated_at_utc = ? WHERE id = 'e2e-completion'` ) .run(now, now); })(); database.close(); } function removeCompletionFixture() { 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.transaction(() => { database .prepare( `DELETE FROM show_share_sessions WHERE share_id IN ( SELECT id FROM show_shares WHERE show_id = 'e2e-completion' )` ) .run(); database.prepare("DELETE FROM show_shares WHERE show_id = 'e2e-completion'").run(); database.prepare("DELETE FROM piece_usage_history WHERE show_id = 'e2e-completion'").run(); database.prepare("DELETE FROM shows WHERE id = 'e2e-completion'").run(); database.prepare("DELETE FROM piece_media WHERE id = 'e2e-share-media'").run(); })(); database.close(); rmSync(path.join(dataDirectory, 'uploads', 'e2e-share.png'), { force: true }); } function addRssSelectionFixture() { 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 now = '2026-10-26T12:00:00.000Z'; const insertFeed = database.prepare( `INSERT INTO feed_sources (id, name, url, is_enabled, refresh_interval_minutes, created_at_utc, updated_at_utc) VALUES (?, ?, ?, 1, 60, ?, ?)` ); const insertEntry = database.prepare( `INSERT INTO feed_entries (id, feed_id, external_id, title, url, summary, published_at_utc, first_seen_at_utc, updated_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` ); database.transaction(() => { insertFeed.run('e2e-feed-a', 'E2E Quelle A', 'https://example.org/e2e-a.xml', now, now); insertFeed.run('e2e-feed-b', 'E2E Quelle B', 'https://example.org/e2e-b.xml', now, now); for (let index = 1; index <= 25; index += 1) { const published = new Date(Date.UTC(2026, 8, index)).toISOString(); insertEntry.run( `e2e-entry-a-${index}`, 'e2e-feed-a', `a-${index}`, `E2E Quelle A Beitrag ${index}`, `https://example.org/a/${index}`, `Auszug für Beitrag ${index} aus Quelle A.`, published, now, now ); } for (let index = 1; index <= 20; index += 1) { const published = new Date(Date.UTC(2026, 7, index)).toISOString(); insertEntry.run( `e2e-entry-b-${index}`, 'e2e-feed-b', `b-${index}`, `E2E Quelle B Beitrag ${index}`, `https://example.org/b/${index}`, `Auszug für Beitrag ${index} aus Quelle B.`, published, now, now ); } })(); database.close(); } function removeIntegrationFixtures() { 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.transaction(() => { const pieceIds = database .prepare( "SELECT piece_id AS id FROM feed_entries WHERE id LIKE 'e2e-entry-%' AND piece_id IS NOT NULL" ) .all() as { id: string }[]; database.prepare("DELETE FROM feed_sources WHERE id LIKE 'e2e-feed-%'").run(); for (const piece of pieceIds) database.prepare('DELETE FROM pieces WHERE id = ?').run(piece.id); const accessIds = database .prepare("SELECT id FROM technical_accesses WHERE name LIKE 'E2E API-Zugang%'") .all() as { id: string }[]; for (const access of accessIds) { database.prepare('DELETE FROM audit_events WHERE target_id = ?').run(access.id); database.prepare('DELETE FROM technical_accesses WHERE id = ?').run(access.id); } })(); 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-25T10:00:00.000Z'; database.transaction(() => { insert.run('e2e-calendar-late', 'Kalender Spättermin', '2026-10-25T18:00:00.000Z', now, now); insert.run('e2e-calendar-early', 'Kalender Frühtermin', '2026-10-25T09: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 setUsagePreparation( usageId: string, status: 'preparing' | 'ready', effort: 'low' | 'medium' | 'high' | 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 piece_usages SET preparation_status_snapshot = ?, preparation_effort_snapshot = ? WHERE id = ?` ) .run(status, effort, usageId); 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.locator('.app-header .account-menu > summary').click(); 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('dashboard, global search and responsive primary navigation use the shared app chrome', async ({ page }, testInfo) => { await page.setViewportSize({ width: 1440, height: 1000 }); await page.goto('/'); await expect(page.getByRole('heading', { name: 'Was als Nächstes ansteht' })).toBeVisible(); for (const label of ['Start', 'Eingang', 'Pieces', 'Streams', 'Live', 'Post']) { await expect( page.locator('.main-navigation').getByRole('link', { name: label, exact: true }) ).toBeVisible(); } await page.getByPlaceholder('Pieces und Streams suchen').fill('Herstellerangaben'); await page.getByPlaceholder('Pieces und Streams suchen').press('Enter'); await expect(page).toHaveURL(/\/suche\?q=Herstellerangaben/); await expect(page.getByRole('heading', { name: 'Pieces', exact: true })).toBeVisible(); await expect(page.getByText('Die kleine Konsole mit dem großen Dreh')).toBeVisible(); await expect(page.getByText('Recherche', { exact: true })).toBeVisible(); await expect(page.locator('.search-filter-panel')).toBeVisible(); await page.screenshot({ path: testInfo.outputPath('suche-desktop.png'), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); await page.getByRole('button', { name: 'Filter' }).click(); await expect(page.locator('.search-filter-panel')).toBeVisible(); await expect(page.getByLabel('Piece-Thema')).toBeVisible(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('suche-mobile-filter.png'), fullPage: true }); await page.goto('/'); await expect(page.locator('.app-header')).toBeHidden(); await expect( page.locator('.mobile-header').getByPlaceholder('Suchen', { exact: true }) ).toBeVisible(); await expect(page.locator('.mobile-navigation').getByRole('link')).toHaveCount(6); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('start-mobile.png'), fullPage: true }); }); test('post-production manages completed shows, source material, videos, clips and valid exports', async ({ page }, testInfo) => { test.setTimeout(60_000); await page.setViewportSize({ width: 1440, height: 1000 }); await page.goto('/post'); await expect( page.locator('.post-page-heading').getByRole('heading', { name: 'Postproduktion' }) ).toBeVisible(); await expect(page.locator('.post-navigation-panel')).toHaveClass(/fundus-navigation/); await expect(page.locator('.content-quick-inspector')).toHaveClass(/stream-inspector/); const completedShow = page.locator('.post-show-row').filter({ hasText: 'Frühere Sendung: Linux, Spiele und Netzkultur' }); await expect(completedShow).toContainText('Noch nicht begonnen'); await expect(completedShow).toContainText('0'); await page.screenshot({ path: testInfo.outputPath('post-overview-1440.png'), fullPage: true }); for (const [width, height] of [ [960, 900], [390, 844] ] as const) { await page.setViewportSize({ width, height }); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath(`post-overview-${width}.png`), fullPage: true }); } await page.setViewportSize({ width: 1440, height: 1000 }); await completedShow.click(); await expect(page.locator('.post-workspace')).toBeVisible(); await expect(page.locator('.post-detail-page')).toBeVisible(); await expect(page.locator('.editor-workspace-dialog')).toHaveCount(0); await expect(page.locator('.post-editor')).toHaveClass(/builder-pane/); await expect(page.locator('.post-inspector')).toHaveClass(/stream-inspector/); await expect(page.locator('.post-navigation')).toContainText('YouTube'); await expect(page.locator('.post-content-overview')).toBeVisible(); await page.screenshot({ path: testInfo.outputPath('post-detail-overview-1440.png'), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); await page.getByRole('button', { name: 'Inhalte', exact: true }).click(); await expect(page.locator('.post-navigation')).toBeVisible(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('post-mobile-navigation.png'), fullPage: true }); await page.setViewportSize({ width: 1440, height: 1000 }); await page.getByRole('link', { name: 'Postproduktion', exact: true }).first().click(); await expect(page).toHaveURL(/\/post$/); await completedShow.click(); await expect(page.locator('.post-workspace')).toBeVisible(); await page.goBack(); await expect(page).toHaveURL(/\/post$/); await completedShow.click(); await expect(page.locator('.post-workspace')).toBeVisible(); await page.getByRole('button', { name: /Material & Quellen/ }).click(); const materials = page.getByRole('dialog', { name: 'Aufzeichnungen & Kontextdateien' }); await materials.getByText('Aufzeichnungsteil hinzufügen').click(); await materials.getByLabel('Bezeichnung').fill('Teil 1'); await materials.getByLabel('Mediendateipfad').fill('/media/heinzlive-teil-1.mov'); await materials.getByLabel('Framerate').fill('25'); await materials.getByLabel('Timebase').fill('25'); await materials.getByRole('button', { name: 'Teil anlegen' }).click(); await page.getByRole('button', { name: /Material & Quellen/ }).click(); await materials.locator('input[name="source"]').setInputFiles({ name: 'heinzlive-kontext-teil-1.md', mimeType: 'text/markdown', buffer: Buffer.from('# Kontext\n\n[00:01:12] Linux ist keine Religion!') }); await materials.getByLabel('Aufzeichnungsteil').selectOption({ label: 'Teil 1' }); await materials.getByRole('button', { name: 'Markdown importieren' }).click(); await page.getByRole('button', { name: 'YouTube anlegen' }).click(); await expect(page.getByRole('heading', { name: 'Uncut-Video' })).toBeVisible(); await page.getByLabel('Arbeitstitel').fill('Linux, Spiele und Netzkultur – Uncut'); await page .getByPlaceholder('Beschreibung, Kapitel, Links und Hashtags') .fill('00:00 Begrüßung\n01:12 Linux ist keine Religion!\n\n#heinzlive'); await page .locator('.post-inspector') .getByLabel('Aufzeichnungsteil') .selectOption({ label: 'Teil 1' }); await expect(page.locator('.post-inspector').getByLabel('Kontextdatei')).toHaveValue(/.+/); await page.locator('.post-inspector select[name="status"]').selectOption('review'); await page.getByRole('button', { name: 'Speichern', exact: true }).first().click(); await expect(page.locator('.post-editor-header')).toContainText('Gespeichert'); const thumbnail = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64' ); await page.locator('.post-thumbnail-upload').getByLabel('Bild').setInputFiles({ name: 'uncut-thumbnail.png', mimeType: 'image/png', buffer: thumbnail }); await page .locator('.post-thumbnail-upload') .getByLabel('Alternativtext') .fill('Fertiges Uncut-Thumbnail'); await page.getByRole('button', { name: 'Thumbnail hochladen' }).click(); await expect(page.locator('.post-thumbnail-grid img')).toBeVisible(); const thumbnailDownload = page.getByRole('link', { name: 'Herunterladen' }); const downloadResponse = await page.request.get( (await thumbnailDownload.getAttribute('href')) as string ); expect(await downloadResponse.body()).toEqual(thumbnail); await page.getByRole('button', { name: 'Social-Clips anlegen' }).click(); await page.getByLabel('Arbeitstitel').fill('Linux ist keine Religion!'); await page .getByPlaceholder('Nur das tatsächliche Originalmaterial eintragen') .fill('Linux ist keine Religion!'); await page .locator('.post-inspector') .getByLabel('Aufzeichnungsteil') .selectOption({ label: 'Teil 1' }); await expect(page.locator('.post-inspector').getByLabel('Kontextdatei')).toHaveValue(/.+/); await page.locator('.post-inspector').getByLabel('Start (Sek.)').fill('72'); await page.locator('.post-inspector').getByLabel('Ende (Sek.)').fill('103'); await page.locator('.post-inspector').getByLabel('Cliptyp').selectOption('opinion'); await page.getByRole('button', { name: 'Plattformtexte' }).click(); await page.getByLabel('Öffentlicher Titel').fill('Linux ist keine Religion!'); await page .getByLabel('Gemeinsame Caption inklusive Hashtags') .fill('Linux ist keine Religion!\n\n#linux #heinzlive'); await page.getByRole('button', { name: 'Hooks & Endcard' }).click(); await page.getByLabel('Primärer Hook').fill('Linux ist keine Religion!'); await page.getByLabel('Schluss-Einblendung').fill('Mehr im vollständigen Stream.'); await page.getByRole('button', { name: 'Schnitt' }).click(); await page .getByLabel('SRT-Untertitel') .fill('1\n00:00:00,000 --> 00:00:02,000\nLinux ist keine Religion!'); await page.getByRole('button', { name: 'Speichern', exact: true }).first().click(); await expect(page.locator('.post-inspector')).toContainText('31 Sekunden'); await expect(page.getByRole('link', { name: 'DaVinci Resolve XML' })).toBeVisible(); await expect(page.getByRole('link', { name: 'SRT-Untertitel' })).toBeVisible(); const xmlHref = await page .getByRole('link', { name: 'DaVinci Resolve XML' }) .getAttribute('href'); const xml = await page.request.get(xmlHref as string); expect(await xml.text()).toContain(''); const srtHref = await page.getByRole('link', { name: 'SRT-Untertitel' }).getAttribute('href'); expect(await (await page.request.get(srtHref as string)).text()).toContain( 'Linux ist keine Religion!' ); await page.screenshot({ path: testInfo.outputPath('post-desktop-clip.png'), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); await page.getByRole('button', { name: 'Details', exact: true }).click(); await expect(page.locator('.post-inspector')).toBeVisible(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('post-mobile-details.png'), fullPage: true }); }); 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: 'Pieces', exact: true }).first()).toBeVisible(); await expect(page.locator('.app-header')).toBeVisible(); await expect(page.locator('.mobile-navigation')).toBeHidden(); await expect(page.locator('.app-header .account-menu > summary')).toBeVisible(); await expect(page.locator('.piece-card')).toHaveCount(1); const navigationBox = await page.locator('.fundus-navigation').boundingBox(); const listBox = await page.locator('.fundus-library-content').boundingBox(); expect(navigationBox?.x).toBeLessThan(listBox?.x ?? 0); await expect(page.locator('.content-quick-inspector')).toBeVisible(); await expect(page.locator('.content-quick-inspector')).toHaveClass(/stream-inspector/); 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 .locator('.filter-picker') .evaluate((element) => ((element as HTMLDetailsElement).open = false)); 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('.piece-card') .filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' }) .getByRole('link', { name: 'Die kleine Konsole mit dem großen Dreh', exact: true }) .click(); await expect(page).toHaveURL(/\/fundus\?includePlanned=1&selected=piece-console$/); await page.getByRole('link', { name: 'Vollständigen Piece-Editor öffnen' }).click(); await expect(page).toHaveURL(/\/fundus\/piece-console\?includePlanned=1$/); await page.keyboard.press('Escape'); await expect(page).toHaveURL(/\/fundus\?includePlanned=1$/); await page .locator('.piece-card') .filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' }) .getByRole('link', { name: 'Die kleine Konsole mit dem großen Dreh', exact: true }) .click(); await page.getByRole('link', { name: 'Vollständigen Piece-Editor öffnen' }).click(); await page.getByRole('button', { name: 'Editor schließen' }).click(); await expect(page).toHaveURL(/\/fundus\?includePlanned=1$/); await expect(page.getByLabel('Auch eingeplante Inhalte zeigen')).toBeChecked(); 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(150); await page.getByRole('searchbox', { name: 'Pieces durchsuchen' }).fill('Pixelart'); await page.getByRole('searchbox', { name: 'Pieces durchsuchen' }).press('Enter'); await expect(page.locator('.piece-card')).toHaveCount(1); await page .locator('.filter-picker') .evaluate((element) => ((element as HTMLDetailsElement).open = true)); await page.locator('.filter-picker').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.locator('.app-header .account-menu > summary').click(); await page.getByRole('button', { name: 'Hell' }).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 page.locator('.app-header .account-menu > summary').click(); await expect(page.getByRole('button', { name: 'Hell' })).toHaveAttribute('aria-pressed', 'true'); await page.locator('.app-header .account-menu > summary').click(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('fundus-desktop-light.png'), fullPage: true }); for (const [width, height] of [ [960, 900], [390, 844] ] as const) { await page.setViewportSize({ width, height }); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath(`fundus-overview-${width}.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.getByRole('link', { name: /Abgeschlossen/ }).click(); 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: 'Pieces durchsuchen' }).fill('praktische Effekte'); await page.getByRole('searchbox', { name: 'Pieces durchsuchen' }).press('Enter'); await expect(page).toHaveURL(/view=completed/); await expect(usedPiece).toBeVisible(); await page.screenshot({ path: testInfo.outputPath('fundus-verwendungshistorie-desktop.png'), fullPage: true }); await page.goto('/sendungen'); await page.locator('.show-create-panel > summary').click(); await page.getByLabel('Titel', { exact: true }).fill('E2E Filterprüfung'); await page.getByRole('radio', { name: /Standardablauf/ }).check(); await page.getByRole('button', { name: 'Stream anlegen' }).click(); await page.getByRole('button', { name: 'Pieces auswählen' }).click(); await page.getByPlaceholder('Pieces 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('Pieces 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('Pieces 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('Pieces 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 Stream 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: 'Pieces', 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).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.locator('.show-calendar-event').filter({ hasText: '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: 'Streams', exact: true }).last()).toBeVisible(); } finally { removeCalendarFixture(); } }); test('preparation opens and updates the concrete scheduled usage', async ({ page }) => { setUsagePreparation('usage-console', 'preparing', 'high'); try { await page.goto('/sendungen'); const preparation = page.locator('.preparation-overview'); await expect(preparation).toContainText('Die kleine Konsole mit dem großen Dreh'); await expect(preparation).toContainText('Hoch'); await preparation.getByRole('link', { name: /Die kleine Konsole/ }).click(); await expect(page).toHaveURL(/usage=usage-console/); const editor = page.locator('.stream-inspector'); await expect(editor).toBeVisible(); await expect(editor).toContainText('Streamfassung'); await editor.getByLabel('Vorbereitungsstatus').selectOption('ready'); await editor.getByRole('button', { name: 'Speichern', exact: true }).click(); await page.goto('/sendungen'); await expect(page.locator('.preparation-overview')).toHaveCount(0); } finally { setUsagePreparation('usage-console', 'ready', null); } }); test('desktop builder opens the piece library beside the wider run sheet on demand', 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')).toBeHidden(); await expect(page.locator('.run-pane')).toBeVisible(); const streamInspector = page.locator('.stream-inspector'); await expect( streamInspector.getByRole('heading', { name: 'Streamdaten und Twitch-Texte' }) ).toBeVisible(); await expect(streamInspector.getByLabel('Twitch-Titel')).toBeVisible(); await expect(page.locator('.mobile-pane-switch')).toBeHidden(); await expect( page.locator('.show-summary strong').filter({ hasText: '1 Std. 25 Min.' }) ).toBeVisible(); await page.getByRole('button', { name: 'Pieces auswählen' }).click(); await expect(page.locator('.source-pane')).toBeVisible(); const sourceBox = await page.locator('.source-pane').boundingBox(); const runBox = await page.locator('.run-pane').boundingBox(); const inspectorBox = await streamInspector.boundingBox(); expect(sourceBox?.x).toBeLessThan(runBox?.x ?? 0); expect(sourceBox?.width ?? 9999).toBeLessThan(runBox?.width ?? 0); expect(runBox?.x ?? 9999).toBeLessThan(inspectorBox?.x ?? 0); expect(inspectorBox?.width ?? 0).toBeGreaterThanOrEqual(288); expect(inspectorBox?.width ?? 9999).toBeLessThanOrEqual(352); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('sendungen-desktop-dark.png'), fullPage: true }); await page.locator('.section-drag-handle').first().click(); await expect(streamInspector).toContainText('Abschnitt'); await expect(streamInspector.getByLabel('Kategorie')).toBeVisible(); await streamInspector.getByRole('button', { name: 'Streamdaten' }).click(); const firstUsage = page.locator('.editable-usage').first(); await firstUsage.getByRole('button', { name: /bearbeiten und verschieben/ }).click(); await expect(streamInspector).toContainText('Streamfassung'); await expect(streamInspector.getByLabel('Dauer in Minuten')).toBeVisible(); await expect(streamInspector.getByRole('link', { name: 'Piece bearbeiten' })).toBeVisible(); const usageFooterBox = await streamInspector.locator('.stream-inspector-actions').boundingBox(); expect(usageFooterBox?.height).toBeLessThan(150); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('stream-usage-editor-desktop-dark.png'), fullPage: true }); await streamInspector.getByRole('button', { name: 'Streamdaten' }).click(); await expect( streamInspector.getByRole('heading', { name: 'Streamdaten und Twitch-Texte' }) ).toBeVisible(); }); test('narrow desktop retains the horizontal navigation and switches the builder panes', async ({ page }) => { await page.setViewportSize({ width: 960, height: 850 }); await openSeededShow(page); await expect(page.locator('.app-header')).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: 'Pieces', 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: 'Pieces', 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.locator('.show-create-panel > summary').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: 'Stream 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.getByRole('button', { name: 'Pieces auswählen' }).click(); await page.getByPlaceholder('Pieces 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(); const streamInspector = page.locator('.stream-inspector'); await expect(streamInspector).toContainText('Streamfassung'); await expect(streamInspector.locator('.stream-inspector-form')).toBeVisible(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('verwendungseditor-desktop.png'), fullPage: true }); await streamInspector.getByText('Weitere Aktionen', { exact: true }).click(); await streamInspector.getByRole('button', { name: 'Aus Piece aktualisieren' }).click(); 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(); let usageDialog = page.locator('.stream-inspector'); await usageDialog.getByLabel('Interner Titel').fill('Konsole als Auftakt'); await usageDialog.getByLabel('Dauer in Minuten').fill('5'); await usageDialog .getByRole('textbox', { name: 'Eigene Notizen', exact: true }) .fill('Kurbel zuerst zeigen'); await usageDialog.getByRole('button', { name: 'Speichern', exact: true }).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(); usageDialog = page.locator('.stream-inspector'); await usageDialog.getByLabel('Interner Titel').fill('Konsole als Rückblick'); await usageDialog.getByLabel('Dauer in Minuten').fill('15'); await usageDialog .getByRole('textbox', { name: 'Eigene Notizen', exact: true }) .fill('Andere Perspektive'); await usageDialog.getByRole('button', { name: 'Speichern', exact: true }).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 streamInspector.getByText('Weitere Aktionen', { exact: true }).click(); await streamInspector.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 page.getByRole('button', { name: 'Pieces auswählen' }).click(); 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(streamInspector).toContainText('Streamfassung'); await streamInspector.getByText('Weitere Aktionen', { exact: true }).click(); await expect(streamInspector.getByRole('button', { name: 'Duplizieren' })).toBeVisible(); await streamInspector.getByRole('button', { name: 'Streamdaten' }).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(page.locator('.stream-inspector [name="ownNotes"]')).toHaveValue( 'Kurbel zuerst zeigen' ); await streamInspector.getByRole('button', { name: 'Streamdaten' }).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(streamInspector.getByLabel('Dauer in Minuten')).toHaveValue('15'); await expect(page.locator('.stream-inspector [name="ownNotes"]')).toHaveValue( 'Andere Perspektive' ); await streamInspector.getByRole('button', { name: 'Streamdaten' }).click(); await page.setViewportSize({ width: 390, height: 844 }); await savedUsage .getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' }) .click(); await expect(streamInspector.locator('.stream-inspector-form')).toBeVisible(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('verwendungseditor-mobile.png'), fullPage: true }); await streamInspector.getByText('Weitere Aktionen', { exact: true }).click(); await streamInspector .getByRole('button', { name: 'Aus Stream entfernen' }) .scrollIntoViewIfNeeded(); await expect(streamInspector.getByRole('button', { name: 'Aus Stream entfernen' })).toBeVisible(); await page .locator('.stream-inspector form[action="?/updateUsageMetadata"] input[name="revision"]') .evaluate((element) => ((element as HTMLInputElement).value = '1')); await streamInspector.getByRole('button', { name: 'Speichern', exact: true }).click(); await expect(page.getByRole('alert')).toContainText('inzwischen geändert'); 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 .locator('.piece-card') .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 }, testInfo) => { for (const [width, height] of [ [390, 844], [960, 900], [1440, 1000] ] as const) { await page.setViewportSize({ width, height }); await page.goto('/eingang'); await expect(page.locator('.capture-panel')).toBeVisible(); await expect(page.locator('.inbox-catalog-row')).toHaveCount(1); await expect(page.locator('.content-quick-inspector')).toBeVisible(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath(`inbox-overview-${width}.png`), fullPage: true }); 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(); await expect(topicIconPicker.getByPlaceholder('Icon suchen')).toBeFocused(); 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('rss selection and integration catalogs keep deliberate choices', async ({ page }, testInfo) => { addRssSelectionFixture(); try { await page.context().route('https://example.org/**', (route) => route.fulfill({ contentType: 'text/html', body: 'RSS-Original' }) ); await page.setViewportSize({ width: 1100, height: 760 }); await setTheme(page, 'light'); await page.goto('/eingang'); const rssPanel = page.locator('.rss-inbox-panel'); await expect(rssPanel).toContainText('45 Beiträge sind verfügbar'); await page.getByRole('button', { name: 'RSS-Beiträge auswählen' }).click(); const rssDialog = page.getByRole('dialog', { name: 'RSS-Beiträge auswählen' }); await expect(rssDialog).toBeVisible(); await expect(rssDialog.getByRole('option')).toHaveCount(20); await rssDialog.getByRole('button', { name: 'E2E Quelle B' }).click(); await expect(rssDialog).toContainText('25 verfügbare Beiträge'); const firstChoice = rssDialog.getByRole('option', { name: /E2E Quelle A Beitrag 25/ }); await firstChoice.press('Enter'); await expect(firstChoice).toHaveAttribute('aria-selected', 'true'); const [original] = await Promise.all([ page.waitForEvent('popup'), firstChoice.getByRole('link', { name: 'Original öffnen' }).click() ]); await original.close(); await expect(firstChoice).toHaveAttribute('aria-selected', 'true'); await rssDialog.getByRole('button', { name: 'Weiter' }).click(); const secondChoice = rssDialog.getByRole('option', { name: /E2E Quelle A Beitrag 1/ }); await secondChoice.press(' '); await expect(secondChoice).toHaveAttribute('aria-selected', 'true'); await rssDialog.getByRole('button', { name: 'E2E Quelle B' }).click(); await expect( rssDialog.getByRole('button', { name: 'Auswahl in den Eingang übernehmen (2)' }) ).toBeEnabled(); await page.setViewportSize({ width: 390, height: 844 }); await page.evaluate(() => (document.documentElement.dataset.theme = 'dark')); await expectNoHorizontalOverflow(page); await expect( rssDialog.getByRole('button', { name: 'Auswahl in den Eingang übernehmen (2)' }) ).toBeVisible(); await page.screenshot({ path: testInfo.outputPath('rss-selection-mobile-dark.png') }); await rssDialog.getByRole('button', { name: 'Auswahl in den Eingang übernehmen (2)' }).click(); await expect(page.getByRole('status')).toContainText('2 Beiträge in den Eingang übernommen'); await expect(rssPanel).toContainText('43 Beiträge sind verfügbar'); await expect(page.getByText('E2E Quelle A Beitrag 25', { exact: true })).toBeVisible(); await page.getByRole('button', { name: 'RSS-Beiträge auswählen' }).click(); await expect(rssDialog.getByText('E2E Quelle A Beitrag 25', { exact: true })).toHaveCount(0); await expect(rssDialog.getByText('E2E Quelle A Beitrag 1', { exact: true })).toHaveCount(0); await rssDialog.getByRole('button', { name: 'RSS-Auswahl schließen' }).click(); await page.setViewportSize({ width: 1100, height: 800 }); await setTheme(page, 'light'); await page.goto('/verwaltung'); await page.getByRole('button', { name: 'Feed hinzufügen' }).click(); let feedDialog = page.getByRole('dialog', { name: 'Feed hinzufügen' }); await feedDialog.getByLabel('Name').fill('E2E Feed Verwaltung'); await feedDialog.getByLabel('Feed-URL').fill('https://example.org/e2e-admin.xml'); await feedDialog.getByLabel('Intervall in Minuten').fill('90'); await feedDialog.getByLabel('Standardkategorie').selectOption({ index: 1 }); await feedDialog.getByRole('button', { name: 'Feed anlegen' }).click(); const feedEntry = page.getByRole('button', { name: 'E2E Feed Verwaltung bearbeiten' }); await expect(feedEntry).toBeVisible(); await feedEntry.click(); feedDialog = page.getByRole('dialog', { name: 'E2E Feed Verwaltung' }); await feedDialog.getByLabel('Name').fill('E2E Feed Verwaltung bearbeitet'); await feedDialog.getByLabel('Intervall in Minuten').fill('120'); await feedDialog.getByRole('button', { name: 'Änderungen speichern' }).click(); await expect( page.getByRole('button', { name: 'E2E Feed Verwaltung bearbeitet bearbeiten' }) ).toBeVisible(); await page.setViewportSize({ width: 390, height: 844 }); await page.evaluate(() => (document.documentElement.dataset.theme = 'dark')); await page.getByRole('button', { name: 'E2E Feed Verwaltung bearbeitet bearbeiten' }).click(); feedDialog = page.getByRole('dialog', { name: 'E2E Feed Verwaltung bearbeitet' }); await expect(feedDialog.getByRole('button', { name: 'Änderungen speichern' })).toBeVisible(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('feed-editor-mobile-dark.png') }); await feedDialog.getByRole('button', { name: 'Feeddialog schließen' }).click(); await page.setViewportSize({ width: 1100, height: 800 }); await page.evaluate(() => (document.documentElement.dataset.theme = 'light')); await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); await page.getByRole('button', { name: 'Zugang hinzufügen' }).click(); let accessDialog = page.getByRole('dialog', { name: 'API-Zugang hinzufügen' }); await accessDialog.getByLabel('Name').fill('E2E API-Zugang'); await accessDialog.getByLabel('Berechtigung').selectOption('read_write'); await accessDialog.getByRole('button', { name: 'Zugang anlegen' }).click(); await expect(accessDialog.getByText('Token, einmalig sichtbar')).toBeVisible(); const token = await accessDialog.locator('code').textContent(); expect(token?.length ?? 0).toBeGreaterThan(20); await accessDialog.getByRole('button', { name: 'Token kopieren' }).click(); await expect(accessDialog.getByText('Token kopiert.')).toBeVisible(); expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(token); await accessDialog.getByRole('button', { name: 'Zugangsdialog schließen' }).click(); await page.getByRole('button', { name: 'E2E API-Zugang bearbeiten' }).click(); accessDialog = page.getByRole('dialog', { name: 'E2E API-Zugang' }); await accessDialog.getByLabel('Name').fill('E2E API-Zugang bearbeitet'); await accessDialog.getByLabel('Berechtigung').selectOption('read'); await accessDialog.getByRole('button', { name: 'Änderungen speichern' }).click(); await expect( page.getByRole('button', { name: 'E2E API-Zugang bearbeitet bearbeiten' }) ).toContainText('Nur lesen'); await page.screenshot({ path: testInfo.outputPath('integration-catalogs-light.png'), fullPage: true }); } finally { removeIntegrationFixtures(); } }); 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 openEditorialField(page, 'Private Recherche'); 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')).toHaveText('Ungespeicherte Änderungen'); await expect(page.locator('.save-indicator')).toHaveText('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 openEditorialField(page, 'Private Recherche'); 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('live is a read-only Stream projection with local selection', async ({ page }, testInfo) => { const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR; if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.'); const databasePath = path.join(dataDirectory, 'e2e.sqlite'); const state = () => { const database = new Database(databasePath, { readonly: true }); const show = database .prepare('SELECT status, revision FROM shows WHERE id = ?') .get('show-october'); const usages = database .prepare( `SELECT u.id, u.status, u.position, u.show_section_id AS sectionId FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id WHERE s.show_id = ? ORDER BY s.position, u.position` ) .all('show-october'); database.close(); return JSON.stringify({ show, usages }); }; const before = state(); await page.setViewportSize({ width: 1440, height: 1000 }); await page.goto('/live?show=show-october'); await expect(page.locator('.app-header')).toBeVisible(); await expect(page.getByRole('heading', { name: 'Herbstsendung: Kleine Welten' })).toBeVisible(); await expect(page.locator('.live-readonly-state')).toContainText('nur lesen'); await expect(page.locator('.live-view-page form')).toHaveCount(1); await expect( page.getByRole('button', { name: /Stream starten|Erledigen|Überspringen|Pausieren|Sortieren/ }) ).toHaveCount(0); const usageButtons = page.locator('.live-view-usage'); await expect(usageButtons).toHaveCount(3); const second = usageButtons.nth(1); await second.click(); await expect(page.locator('#live-piece-title')).toHaveText( 'Warum praktische Effekte wieder auffallen' ); await expect(second).toHaveAttribute('aria-pressed', 'true'); const selectedStyle = await second.evaluate((element) => { const style = getComputedStyle(element); return { radius: Number.parseFloat(style.borderRadius), border: style.borderColor, background: style.backgroundColor, shadow: style.boxShadow }; }); expect(selectedStyle.radius).toBeGreaterThan(0); expect(selectedStyle.border).not.toBe('rgba(0, 0, 0, 0)'); expect(selectedStyle.background).not.toBe('rgba(0, 0, 0, 0)'); expect(selectedStyle.shadow).toBe('none'); expect(state()).toBe(before); await page.getByRole('button', { name: 'Präsentieren', exact: true }).click(); expect(state()).toBe(before); const desktopColumns = await page.evaluate(() => { const rundown = document.querySelector('.live-view-rundown')?.getBoundingClientRect(); const content = document.querySelector('.live-view-content')?.getBoundingClientRect(); const details = document.querySelector('.live-view-details')?.getBoundingClientRect(); return Boolean(rundown && content && details && content.x > rundown.x && details.x > content.x); }); expect(desktopColumns).toBe(true); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('live-readonly-desktop.png'), fullPage: true }); const rejectedWrite = await page.request.post('/live?show=show-october&/start', { form: { showId: 'show-october', revision: '0', commandId: 'e2e-forbidden' } }); expect(rejectedWrite.status()).toBeGreaterThanOrEqual(400); expect(state()).toBe(before); await page.setViewportSize({ width: 960, height: 900 }); await page.reload(); await expect(page.locator('.live-view-switch')).toBeHidden(); await expect(page.locator('.live-view-rundown')).toBeVisible(); await expect(page.locator('.live-view-content')).toBeVisible(); await expect(page.locator('.live-view-details')).toBeVisible(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('live-readonly-compact.png'), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); await expect(page.locator('.mobile-navigation')).toBeVisible(); await page.getByRole('button', { name: 'Details', exact: true }).click(); await expect(page.locator('.live-view-details')).toBeVisible(); await page.getByRole('button', { name: 'Schrift vergrößern' }).click(); await page.getByRole('button', { name: 'Inhalt', exact: true }).click(); await expect(page.locator('.live-view-content')).toBeVisible(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('live-readonly-mobile.png'), fullPage: true }); expect(state()).toBe(before); }); test('completion, archive and protected guest sharing work end to end', async ({ page, browser }, testInfo) => { addCompletionFixture(); try { await page.goto('/sendungen/e2e-completion'); await page.getByRole('button', { name: 'Lesefreigaben' }).click(); const planningShares = page.locator('.share-management'); await expect(planningShares.getByText('Aktualisiert sich bis zum Abschluss')).toBeVisible(); await planningShares.getByLabel('Informationsumfang').selectOption('moderation'); await planningShares.getByRole('button', { name: 'Leselink anlegen' }).click(); const planningSharePath = await planningShares.locator('.share-result a').getAttribute('href'); expect(planningSharePath).toMatch(/^\/freigabe\//); await page.screenshot({ path: testInfo.outputPath('share-editor-desktop.png'), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('share-editor-mobile.png'), fullPage: true }); await page.setViewportSize({ width: 1280, height: 900 }); const planningGuest = await browser.newContext(); const planningGuestPage = await planningGuest.newPage(); await planningGuestPage.goto(planningSharePath!); await expect(planningGuestPage.getByText('Aktuelle Streamplanung · Nur lesen')).toBeVisible(); await expect(planningGuestPage.getByText('Gesendeter Abschlussbeitrag')).toBeVisible(); await expect(planningGuestPage.getByText('FREIGABE_MODERATION')).toBeVisible(); const guestThemePreference = await planningGuestPage .locator('html') .getAttribute('data-theme-preference'); await planningGuestPage.getByRole('button', { name: /Darstellung:/ }).click(); await expect(planningGuestPage.locator('html')).not.toHaveAttribute( 'data-theme-preference', guestThemePreference! ); const planningShareRow = planningShares.locator('.share-row').first(); await planningShareRow.locator('summary').getByText('Ablauf und Moderation').click(); await planningShareRow.getByLabel('Informationsumfang').selectOption('research'); await planningShareRow.locator('select[name="passwordMode"]').selectOption('replace'); await planningShareRow.locator('input[name="password"]').fill('Planung-Freigabe-Passwort'); await planningShareRow.getByRole('button', { name: 'Freigabe speichern' }).click(); await planningGuestPage.reload(); await expect( planningGuestPage.getByRole('heading', { name: 'Passwort erforderlich' }) ).toBeVisible(); page.once('dialog', (dialog) => dialog.accept()); await planningShares .locator('.share-row') .first() .locator('summary') .getByText('Vollständig mit Recherche') .click(); await planningShares.getByRole('button', { name: 'Freigabe löschen' }).click(); await planningGuestPage.reload(); await expect(planningGuestPage.getByRole('heading', { name: 'Nicht verfügbar' })).toBeVisible(); await planningGuest.close(); finalizeCompletionFixture(); await page.goto('/sendungen?state=completed'); await expect( page.locator('.show-card-list').getByRole('link', { name: /E2E Abschluss/ }) ).toBeVisible(); await page .locator('.show-card-list') .getByRole('link', { name: /E2E Abschluss/ }) .click(); await expect(page.getByText('Unveränderliche Streamhistorie')).toBeVisible(); await expect(page.getByText('Gesendeter Abschlussbeitrag')).toBeVisible(); await expect(page.getByText('Übersprungener Abschlussbeitrag')).toBeVisible(); await expect(page.getByRole('button', { name: 'Streamdaten speichern' })).toHaveCount(0); expect( await page .locator('.historical-section') .first() .evaluate((element) => Number.parseFloat(getComputedStyle(element).paddingInlineStart)) ).toBeGreaterThan(0); await page.goto('/verwaltung'); const shareCatalog = page.locator('.share-catalog-panel'); await expect(shareCatalog).toBeVisible(); const catalogOrder = await page.evaluate(() => { const share = document.querySelector('.share-catalog-panel')?.getBoundingClientRect(); const tools = document.querySelector('.tool-management')?.getBoundingClientRect(); return share && tools ? share.bottom <= tools.top : false; }); expect(catalogOrder).toBe(true); await page.getByRole('button', { name: 'Lesefreigaben' }).click(); const shareManagement = page.locator('.share-management'); await expect(page.locator('.share-management-dialog')).toBeVisible(); const dialogStyle = await page.locator('.share-management-dialog').evaluate((element) => ({ borderRadius: Number.parseFloat(getComputedStyle(element).borderRadius), background: getComputedStyle(element).backgroundColor })); expect(dialogStyle.borderRadius).toBeGreaterThan(0); expect(dialogStyle.background).not.toBe('rgba(0, 0, 0, 0)'); await shareManagement.getByLabel('Stream').selectOption('e2e-completion'); await shareManagement.getByLabel('Informationsumfang').selectOption('moderation'); await shareManagement.getByLabel('Optionales Passwort').fill('E2E-Freigabe-Passwort'); await shareManagement.getByRole('button', { name: 'Leselink anlegen' }).click(); const shareLink = page.locator('.share-result a'); await expect(shareLink).toBeVisible(); const sharePath = await shareLink.getAttribute('href'); expect(sharePath).toMatch(/^\/freigabe\//); await page.screenshot({ path: testInfo.outputPath('share-admin-desktop.png'), fullPage: true }); const guest = await browser.newContext(); const guestPage = await guest.newPage(); await guestPage.goto(sharePath!); await expect(guestPage.getByRole('heading', { name: 'Passwort erforderlich' })).toBeVisible(); const passwordHtml = await guestPage.content(); expect(passwordHtml).not.toContain('password_hash'); expect(passwordHtml).not.toContain('e2e-completion'); await guestPage.getByLabel('Passwort').fill('falsch'); await guestPage.getByRole('button', { name: 'Stream öffnen' }).click(); await expect(guestPage.getByRole('alert')).toContainText('Passwort falsch'); await guestPage.getByLabel('Passwort').fill('E2E-Freigabe-Passwort'); await guestPage.getByRole('button', { name: 'Stream öffnen' }).click(); await expect(guestPage.getByRole('heading', { name: 'E2E Abschluss' })).toBeVisible(); await expect(guestPage.getByText('FREIGABE_MODERATION')).toBeVisible(); await expect(guestPage.getByText('PRIVATE_FREIGABE_RECHERCHE')).toHaveCount(0); await expect(guestPage.getByText('PRIVATE_FREIGABE_NOTIZ')).toHaveCount(0); await expect(guestPage.getByAltText('Freigegebenes Abschlussbild')).toBeVisible(); expect( await guestPage .locator('.shared-section') .first() .evaluate((element) => Number.parseFloat(getComputedStyle(element).paddingInlineStart)) ).toBeGreaterThan(0); await guestPage.setViewportSize({ width: 390, height: 844 }); await expectNoHorizontalOverflow(guestPage); await guestPage.screenshot({ path: testInfo.outputPath('share-mobile.png'), fullPage: true }); const imagePath = await guestPage .getByAltText('Freigegebenes Abschlussbild') .getAttribute('src'); const sharedImage = await guest.request.get(imagePath!); expect(sharedImage.status()).toBe(200); expect(sharedImage.headers()['content-security-policy']).toBe("default-src 'none'; sandbox"); expect((await guest.request.get(`${sharePath}/media/foreign-id`)).status()).toBe(404); const guestWrite = await guest.request.post('/sendungen/e2e-completion?/saveMetadata', { maxRedirects: 0, form: { title: 'Verboten', revision: '2', status: 'draft' } }); expect(guestWrite.status()).toBe(403); await page.goto('/verwaltung'); await page.getByRole('button', { name: 'Lesefreigaben' }).click(); const showCard = page.locator('.share-show-card').filter({ hasText: 'E2E Abschluss' }); await expect(showCard.getByText('Abgeschlossen')).toBeVisible(); const shareRow = showCard.locator('.share-row').first(); await shareRow.locator('summary').getByText('Ablauf und Moderation').click(); await shareRow.getByLabel('Informationsumfang').selectOption('rundown'); await shareRow.locator('select[name="passwordMode"]').selectOption('remove'); await shareRow.getByRole('button', { name: 'Freigabe speichern' }).click(); await guestPage.reload(); await expect(guestPage.getByRole('heading', { name: 'E2E Abschluss' })).toBeVisible(); await expect(guestPage.getByText('FREIGABE_MODERATION')).toHaveCount(0); page.once('dialog', (dialog) => dialog.accept()); const updatedCard = page.locator('.share-show-card').filter({ hasText: 'E2E Abschluss' }); await updatedCard .locator('.share-row') .first() .locator('summary') .getByText('Nur Ablauf') .click(); await updatedCard.getByRole('button', { name: 'Freigabe löschen' }).click(); await guestPage.reload(); await expect(guestPage.getByRole('heading', { name: 'Nicht verfügbar' })).toBeVisible(); expect((await guest.request.get(imagePath!)).status()).toBe(404); await guest.close(); } finally { removeCompletionFixture(); } }); test('piece writing room stays focused across content, themes and narrow screens', async ({ page }, testInfo) => { await page.setViewportSize({ width: 1440, height: 1000 }); await setTheme(page, 'light'); await page.goto('/fundus/piece-console'); const writing = page.locator('.piece-writing-stack'); const context = page.locator('.piece-meta-details'); await expect(writing).toBeVisible(); await expect(context).toBeVisible(); await expect(page.locator('.app-header .brand-version')).toHaveText('0.9.0'); await expect(page.locator('.fundus-navigation')).toHaveCount(0); const pieceDialog = page.getByRole('dialog', { name: /Die kleine Konsole/ }); await expect(pieceDialog).toBeVisible(); await pieceDialog.evaluate(async (element) => { await Promise.all( element.getAnimations({ subtree: true }).map((animation) => animation.finished) ); }); const titleRowBox = await page.locator('.piece-editor-title-row').boundingBox(); const headerActionsBox = await page.locator('.piece-editor-header-actions').boundingBox(); expect(headerActionsBox?.y).toBeGreaterThanOrEqual( (titleRowBox?.y ?? 0) + (titleRowBox?.height ?? 0) ); const workspaceBox = await page.locator('.piece-dialog-workspace').boundingBox(); const writingBox = await writing.boundingBox(); const contextBox = await context.boundingBox(); const presentationBox = await page.locator('.piece-presentation-panel').boundingBox(); expect((writingBox?.x ?? 0) - (workspaceBox?.x ?? 0)).toBeLessThanOrEqual(18); expect( (contextBox?.x ?? 0) - ((writingBox?.x ?? 0) + (writingBox?.width ?? 0)) ).toBeLessThanOrEqual(18); const firstWritingFieldBox = await page .locator('.piece-writing-stack .rich-markdown-field') .first() .boundingBox(); expect(firstWritingFieldBox?.height).toBeLessThan(170); expect(contextBox?.height).toBeLessThan(500); expect(presentationBox?.y).toBeLessThan(800); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('piece-editor-desktop-expanded-light.png'), fullPage: true }); await setTheme(page, 'dark'); await page.reload(); await expect(writing).toBeVisible(); await page.screenshot({ path: testInfo.outputPath('piece-editor-desktop-overlay-dark.png'), fullPage: true }); await page.setViewportSize({ width: 960, height: 900 }); const narrowDialogBox = await page.locator('.piece-editor-dialog').boundingBox(); expect(narrowDialogBox?.width ?? 0).toBeGreaterThanOrEqual(850); expect(narrowDialogBox?.width ?? 960).toBeLessThan(960); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('piece-editor-narrow-overlay-dark.png'), fullPage: true }); await page.setViewportSize({ width: 1440, height: 1000 }); await page.goto('/fundus'); await page.locator('.template-picker > summary').click(); await page.getByRole('button', { name: /Leer starten/ }).click(); await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/); await expect(page.getByRole('textbox', { name: 'Einleitung', exact: true })).toBeEmpty(); await page.screenshot({ path: testInfo.outputPath('piece-editor-empty-desktop-dark.png'), fullPage: true }); const emptyPieceUrl = page.url(); const emptyIntroField = page.locator('.rich-markdown-field:has(input[name="intro"])'); const compactIntroBox = await emptyIntroField.boundingBox(); await emptyIntroField .getByRole('textbox', { name: 'Einleitung', exact: true }) .fill(Array.from({ length: 8 }, (_, index) => `Absatz ${index + 1}`).join('\n\n')); const expandedIntroBox = await emptyIntroField.boundingBox(); expect(expandedIntroBox?.height).toBeGreaterThan((compactIntroBox?.height ?? 0) + 80); const introEditor = emptyIntroField.getByRole('textbox', { name: 'Einleitung', exact: true }); await introEditor.fill('Formatierter Einstieg'); await introEditor.press('ControlOrMeta+a'); await expect(emptyIntroField.getByRole('toolbar')).toBeVisible(); await emptyIntroField.getByRole('button', { name: 'Fett' }).click(); await expect(emptyIntroField.locator('input[name="intro"]')).toHaveValue( '**Formatierter Einstieg**' ); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); const closeButton = page.getByRole('button', { name: 'Editor schließen' }); await page.locator('dialog.piece-editor-dialog').evaluate((element) => { sessionStorage.removeItem('piece-editor-close-animation'); element.addEventListener('animationstart', (event) => { if (event.animationName === 'surface-close') { sessionStorage.setItem('piece-editor-close-animation', 'started'); } }); element.addEventListener('animationend', (event) => { if (event.animationName === 'surface-close') { sessionStorage.setItem('piece-editor-close-animation', 'finished'); } }); }); await closeButton.click(); await expect(page).toHaveURL(/\/fundus(?:\?|$)/); expect(await page.evaluate(() => sessionStorage.getItem('piece-editor-close-animation'))).toBe( 'finished' ); await page.goto('/fundus?includePlanned=1'); await page .locator('.piece-card') .filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' }) .getByRole('link', { name: 'Die kleine Konsole mit dem großen Dreh', exact: true }) .click(); await page.getByRole('link', { name: 'Vollständigen Piece-Editor öffnen' }).click(); await expect(page).toHaveURL(/\/fundus\/piece-console/); await page.getByRole('button', { name: 'Editor schließen' }).click(); await page .locator('.piece-card') .filter({ hasText: 'Neues Piece' }) .getByRole('link', { name: 'Neues Piece', exact: true }) .click(); await page.getByRole('link', { name: 'Vollständigen Piece-Editor öffnen' }).click(); await expect(page).toHaveURL( new RegExp(`${emptyPieceUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\?.*)?$`) ); await expect(page.locator('input[name="intro"]')).toHaveValue('**Formatierter Einstieg**'); await page.setViewportSize({ width: 390, height: 844 }); await page.reload(); const navigation = page.getByRole('navigation', { name: 'Arbeitsbereich' }); await navigation.getByRole('button', { name: 'Streamtext', exact: true }).click(); await page.screenshot({ path: testInfo.outputPath('piece-editor-mobile-streamtext-dark.png'), fullPage: true }); await page.getByRole('button', { name: 'Einleitung formatieren' }).click(); await expect( page.locator('.rich-markdown-field:has(input[name="intro"])').getByRole('toolbar') ).toBeVisible(); await navigation.getByRole('button', { name: 'Details & Ausgabe' }).click(); await expect(page.locator('#piece-editor-form').getByLabel('Status')).toBeVisible(); await expect(page.getByRole('radio', { name: 'Text', exact: true })).toBeVisible(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('piece-editor-mobile-dark.png'), fullPage: true }); await navigation.getByRole('button', { name: 'Recherche & Quellen' }).click(); await expect(page.getByRole('textbox', { name: 'Private Recherche', exact: true })).toBeVisible(); await expect(page.locator('#piece-editor-form').getByLabel('Status')).not.toBeVisible(); }); test('pieces autosave safely, detect conflicts and protect uploaded images', async ({ page, browser }, testInfo) => { test.setTimeout(45_000); await page.setViewportSize({ width: 1280, height: 1000 }); await page.goto('/fundus'); await page.locator('.template-picker > summary').click(); await page.getByRole('button', { name: /Reaction \/ Fundstück/ }).click(); await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/); const pieceUrl = page.url(); const writingBox = await page.locator('.piece-writing-stack').first().boundingBox(); const metadataBox = await page.locator('.piece-meta-details').boundingBox(); expect(writingBox?.x).toBeLessThan(metadataBox?.x ?? 0); await expectNoHorizontalOverflow(page); await editInlineTitle(page, 'Neues Fundstück', 'E2E Fundstück mit Markdown'); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await page.getByRole('button', { name: 'Editor schließen' }).click(); await expect(page).toHaveURL(/\/fundus$/); await page.getByRole('link', { name: 'E2E Fundstück mit Markdown', exact: true }).click(); await page.getByRole('link', { name: 'Vollständigen Piece-Editor öffnen' }).click(); await expect(page).toHaveURL(pieceUrl); await openPieceMoreDetails(page); await page .getByLabel('Kurzbeschreibung') .fill('Persistiert nach Neuladen und ist gezielt suchbar.'); await page.getByLabel('Status').selectOption('ready'); await page.getByLabel('Dauer in Minuten').fill('10.25'); await page.getByText('Themen auswählen', { exact: true }).click(); await page.getByLabel('Gaming').check(); await openEditorialField(page, 'Private Recherche'); await fillMarkdownSource(page, 'Private Recherche', 'Nur intern: **Hintergrund prüfen**.'); await openEditorialField(page, 'Moderationsstichpunkte'); await page .getByRole('textbox', { name: 'Moderationsstichpunkte', exact: true }) .fill('Den Kern in zwei Sätzen erklären.'); await openEditorialField(page, 'Private Recherche'); await expect(page.locator('input[name="researchText"]')).toHaveValue( 'Nur intern: **Hintergrund prüfen**.' ); await openEditorialField(page, 'Präsentationstext'); await page.getByLabel('Öffentliche Überschrift').fill('Öffentlicher E2E-Titel'); await fillMarkdownSource( page, 'Öffentlicher Vorspann', '## Sichere Vorschau\n\n **sichtbar**' ); await expect(page.locator('input[name="presentationText"]')).toHaveValue( '## Sichere Vorschau\n\n **sichtbar**' ); await openEditorialField(page, 'Private Recherche'); 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')).toHaveText('Ungespeicherte Änderungen'); await expect(page.locator('.save-indicator')).toHaveText('Gespeichert', { timeout: 10_000 }); await page.reload(); await openEditorialField(page, 'Präsentationstext'); await expect(page.locator('.piece-presentation-preview script')).toHaveCount(0); await expect(page.locator('.piece-presentation-preview')).toContainText('Sichere Vorschau'); await expect( page.getByRole('button', { name: 'E2E Fundstück mit Markdown Titel bearbeiten' }) ).toBeVisible(); await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25'); await expect(page.getByLabel('Öffentliche Überschrift')).toHaveValue('Öffentlicher E2E-Titel'); await openEditorialField(page, 'Private Recherche'); await expect(page.locator('input[name="researchText"]')).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 editInlineTitle(page, 'E2E Fundstück mit Markdown', 'E2E Fundstück, extern aktualisiert'); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await openPieceMoreDetails(concurrent); 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.getByRole('button', { name: 'E2E Fundstück, extern aktualisiert Titel bearbeiten' }) ).toBeVisible(); 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.locator('.piece-card').getByRole('heading', { name: 'E2E Fundstück, extern aktualisiert' }) ).toBeVisible(); await page.getByRole('link', { name: 'E2E Fundstück, extern aktualisiert', exact: true }).click(); await page.getByRole('link', { name: 'Vollständigen Piece-Editor öffnen' }).click(); await page.locator('.piece-action-menu > summary').click(); page.once('dialog', (dialog) => dialog.accept()); 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 .piece-actions > summary').click(); await page.locator('.piece-card').getByRole('button', { name: 'Duplizieren' }).click(); await expect( page.getByRole('button', { name: 'Kopie von E2E Fundstück, extern aktualisiert Titel bearbeiten' }) ).toBeVisible(); await page.context().setOffline(true); await editInlineTitle( page, 'Kopie von E2E Fundstück, extern aktualisiert', 'Kopie mit unterbrochener Verbindung' ); await expect(page.locator('.save-indicator')).toContainText('Speichern fehlgeschlagen', { timeout: 10_000 }); await page.context().setOffline(false); await page.getByRole('button', { name: 'Erneut speichern' }).click(); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await page.setViewportSize({ width: 390, height: 844 }); await setTheme(page, 'dark'); await page.reload(); await page .getByRole('navigation', { name: 'Arbeitsbereich' }) .getByRole('button', { name: 'Recherche & Quellen' }) .click(); await expect(page.getByRole('heading', { name: 'Quellen', exact: true })).toBeVisible(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('piece-editor-mobile-dark.png'), fullPage: true }); }); test('a quickly captured video can be written and previewed before classification', async ({ page }) => { await page.context().route('https://www.youtube-nocookie.com/**', (route) => route.fulfill({ contentType: 'text/html', body: 'YouTube Test' }) ); await page.goto('/eingang'); await page.getByLabel('Gedanke').fill('Schnelles Video-Fundstück'); await page.getByLabel('Link').fill('https://youtu.be/dQw4w9WgXcQ'); await page.getByRole('button', { name: 'Im Eingang ablegen' }).click(); await expect(page).toHaveURL(/\/eingang(?:\?\/capture)?$/); await page.locator('.inbox-catalog-row').filter({ hasText: 'Schnelles Video-Fundstück' }).click(); await page.getByRole('link', { name: 'Piece vollständig bearbeiten' }).click(); await expect(page.locator('.source-row').getByLabel('URL')).toHaveValue( 'https://youtu.be/dQw4w9WgXcQ' ); await expect(page.getByLabel('Status')).toBeVisible(); await openEditorialField(page, 'Private Recherche'); await page .getByRole('textbox', { name: 'Private Recherche', exact: true }) .fill('Warum funktioniert der Einstieg so unmittelbar?'); await openEditorialField(page, 'Moderationsstichpunkte'); await page .getByRole('textbox', { name: 'Moderationsstichpunkte', exact: true }) .fill('Erst Clip zeigen, dann die Beobachtung öffnen.'); await page.getByRole('radio', { name: 'Video', exact: true }).check(); await page.getByRole('combobox', { name: 'Video-URL', exact: true }).selectOption('0'); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); const previewPromise = page.waitForEvent('popup'); await page.getByRole('link', { name: 'In neuem Fenster testen' }).click(); const preview = await previewPromise; await expect(preview.locator('iframe[title="Schnelles Video-Fundstück"]')).toHaveAttribute( 'src', 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ' ); await preview.close(); await page.setViewportSize({ width: 390, height: 844 }); await page .getByRole('navigation', { name: 'Arbeitsbereich' }) .getByRole('button', { name: 'Details & Ausgabe' }) .click(); await expect(page.getByRole('radio', { name: 'Video', exact: true })).toBeVisible(); await openEditorialField(page, 'Private Recherche'); await expect(page.getByRole('textbox', { name: 'Private Recherche', exact: true })).toBeVisible(); await expectNoHorizontalOverflow(page); }); test('link preview uses one existing source, persists manual fields and isolates its test view', async ({ page }, testInfo) => { await page.goto('/fundus/piece-console'); await page.getByRole('radio', { name: 'Linkvorschau', exact: true }).check(); await page.getByRole('combobox', { name: 'Quelle/URL', exact: true }).selectOption('0'); await page.getByLabel('Kartentitel').fill('Manuell korrigierte Linkvorschau'); await page.getByLabel('Anreißer').fill('Ein kurzer manueller Anreißer ohne Vorschaubild.'); await page.getByLabel('Vorschaubild-URL').fill(''); await expect(page.locator('.save-indicator')).toHaveText('Ungespeicherte Änderungen'); await expect(page.locator('.save-indicator')).toHaveText('Gespeichert', { timeout: 10_000 }); await page.reload(); await expect(page.getByRole('radio', { name: 'Linkvorschau', exact: true })).toBeChecked(); await expect(page.getByLabel('Kartentitel')).toHaveValue('Manuell korrigierte Linkvorschau'); const previewPromise = page.waitForEvent('popup'); await page.getByRole('link', { name: 'In neuem Fenster testen' }).click(); const preview = await previewPromise; await preview.waitForLoadState('networkidle'); await expect(preview.getByText('Isolierte Testansicht')).toBeVisible(); await expect( preview.getByRole('heading', { name: 'Manuell korrigierte Linkvorschau' }) ).toBeVisible(); await expect(preview.getByText('Ein kurzer manueller Anreißer ohne Vorschaubild.')).toBeVisible(); await expect(preview.locator('.output-link-image')).toHaveCount(0); await preview.screenshot({ path: testInfo.outputPath('linkvorschau-desktop.png'), fullPage: true }); await preview.setViewportSize({ width: 390, height: 844 }); await expectNoHorizontalOverflow(preview); await preview.screenshot({ path: testInfo.outputPath('linkvorschau-mobile.png'), fullPage: true }); await preview.close(); await openEditorialField(page, 'Private Recherche'); await page.locator('.source-row').first().getByLabel('URL').fill('http://127.0.0.1/private'); await expect(page.locator('.save-indicator')).toHaveText('Ungespeicherte Änderungen'); await expect(page.locator('.save-indicator')).toHaveText('Gespeichert', { timeout: 10_000 }); await page.reload(); const blockedPreview = page.waitForResponse((response) => response.url().includes('?/loadPreview') ); await page.getByRole('button', { name: 'Vorschau laden' }).click(); expect((await blockedPreview).status()).toBe(400); await expect(page.getByRole('alert')).toContainText('Interne oder private Netzwerkziele'); });