import { expect, test, type Page } from '@playwright/test'; import { readFileSync, readdirSync } from 'node:fs'; import path from 'node:path'; const adminEmail = 'barkeeper@example.test'; const adminPassword = 'Nur-fuer-E2E-Admin-1234'; const crewEmail = 'crew@example.test'; const crewPassword = 'Nur-fuer-E2E-Crew-1234'; const routes = ['/fundus', '/sendungen', '/eingang', '/archiv', '/verwaltung', '/live']; const viewports = [ { width: 390, height: 844, theme: 'light' }, { width: 390, height: 844, theme: 'dark' }, { width: 768, height: 900, theme: 'light' }, { width: 768, height: 900, theme: 'dark' }, { width: 1440, height: 1000, theme: 'dark' }, { width: 1440, height: 1000, theme: 'light' } ] as const; async function expectNoHorizontalOverflow(page: Page) { const dimensions = await page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth })); expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth); } async function setTheme(page: Page, theme: 'light' | 'dark') { await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme); } async function login(page: Page, email = adminEmail, password = adminPassword) { await page.goto('/login'); await page.getByLabel('E-Mail').fill(email); await page.getByLabel('Passwort', { exact: true }).fill(password); await page.getByRole('button', { name: 'Anmelden' }).click(); await expect(page).toHaveURL(/\/fundus$/); } async function openSeededShow(page: Page) { await page.goto('/sendungen'); await page.getByRole('link', { name: /Herbstsendung: Kleine Welten/ }).click(); await expect(page).toHaveURL(/\/sendungen\/show-october$/); } function latestMailLink(subject: string): string { const outbox = process.env.SENDEWERK_E2E_OUTBOX_DIR; if (!outbox) throw new Error('E2E-Outbox ist nicht konfiguriert.'); const messages = readdirSync(outbox) .sort() .map( (file) => JSON.parse(readFileSync(path.join(outbox, file), 'utf8')) as { subject: string; text: string; } ) .filter((message) => message.subject === subject); const match = messages.at(-1)?.text.match(/https?:\/\/[^\s]+/); if (!match) throw new Error(`Keine Testmail mit Betreff ${subject} gefunden.`); return match[0]; } test.beforeAll(async ({ browser }) => { const page = await browser.newPage(); await page.goto('/'); if (await page.getByRole('heading', { name: 'Ersten Barkeeper anlegen' }).isVisible()) { await page.getByLabel('Betreiber-Setup-Token').fill('e2e-only-setup-token-32-characters'); await page.getByLabel('Name').fill('E2E Barkeeper'); await page.getByLabel('E-Mail').fill(adminEmail); await page.getByLabel('Passwort').fill(adminPassword); await page.getByRole('button', { name: 'Barkeeper anlegen' }).click(); } else { await page.getByLabel('E-Mail').fill(adminEmail); await page.getByLabel('Passwort').fill(adminPassword); await page.getByRole('button', { name: 'Anmelden' }).click(); } await expect(page).toHaveURL(/\/fundus$/); const sessionCookie = (await page.context().cookies()).find( (cookie) => cookie.name === 'sendewerk_session' ); expect(sessionCookie?.httpOnly).toBe(true); expect(sessionCookie?.secure).toBe(false); expect(sessionCookie?.sameSite).toBe('Lax'); await page.goto('/setup'); await expect(page).toHaveURL(/\/fundus$/); await page.getByRole('button', { name: 'Abmelden', exact: true }).click(); await expect(page).toHaveURL(/\/login$/); await page.close(); }); test.beforeEach(async ({ page }) => { await login(page); }); test('invitations, direct authorization, locking and password reset work end to end', async ({ page, browser }, testInfo) => { await page.goto('/verwaltung'); await page.screenshot({ path: testInfo.outputPath('verwaltung-konten-desktop.png'), fullPage: true }); const crossSite = await page.request.post('/verwaltung?/invite', { form: { email: 'cross-site@example.test', role: 'barkeeper' }, headers: { origin: 'https://example.invalid' } }); expect(crossSite.status()).toBe(403); const inviteForm = page.locator('.invite-panel').filter({ hasText: 'Neues Konto einladen' }); await inviteForm.getByLabel('E-Mail').fill(crewEmail); await inviteForm.getByRole('button', { name: 'Einladung erstellen' }).click(); await expect( page.getByText('Einladung wurde in der lokalen Mail-Outbox abgelegt.') ).toBeVisible(); const crewContext = await browser.newContext(); const crewPage = await crewContext.newPage(); await crewPage.goto(latestMailLink('Einladung zu Sendewerk')); await expect(crewPage).toHaveURL(/\/einladung$/); await crewPage.getByLabel('Name').fill('E2E Crew'); await crewPage.getByLabel('Passwort').fill(crewPassword); await crewPage.getByRole('button', { name: 'Konto aktivieren' }).click(); await expect(crewPage).toHaveURL(/\/fundus$/); await expect(crewPage.getByText('Verwaltung', { exact: true })).toHaveCount(0); const forbidden = await crewPage.request.post('/verwaltung?/invite', { form: { email: 'verboten@example.test', role: 'barkeeper' }, headers: { origin: 'http://127.0.0.1:4173' } }); expect(forbidden.status()).toBe(403); await page.reload(); const crewRow = page.locator('.account-row').filter({ hasText: crewEmail }); await crewRow.getByRole('button', { name: 'Sperren' }).click(); await crewPage.goto('/fundus'); await expect(crewPage).toHaveURL(/\/login$/); await page.reload(); await page .locator('.account-row') .filter({ hasText: crewEmail }) .getByRole('button', { name: 'Entsperren' }) .click(); await crewPage.goto('/passwort-vergessen'); await crewPage.getByLabel('E-Mail').fill(crewEmail); await crewPage.getByRole('button', { name: 'Reset-Link anfordern' }).click(); await expect(crewPage.getByText('Anfrage verarbeitet.')).toBeVisible(); await crewPage.goto(latestMailLink('Sendewerk-Passwort zurücksetzen')); await expect(crewPage).toHaveURL(/\/passwort-zuruecksetzen$/); await crewPage.getByLabel('Neues Passwort').fill('Neues-E2E-Crew-Passwort-1234'); await crewPage.getByRole('button', { name: 'Passwort speichern' }).click(); await login(crewPage, crewEmail, 'Neues-E2E-Crew-Passwort-1234'); await crewContext.close(); const anonymousContext = await browser.newContext({ viewport: { width: 390, height: 844 } }); const loginPage = await anonymousContext.newPage(); await loginPage.goto('/login'); await setTheme(loginPage, 'dark'); await loginPage.reload(); await expectNoHorizontalOverflow(loginPage); await loginPage.screenshot({ path: testInfo.outputPath('login-mobile-dark.png'), fullPage: true }); await anonymousContext.close(); }); test('desktop fundus is a compact, filterable list with persistent themes', async ({ page }, testInfo) => { await page.setViewportSize({ width: 1440, height: 1000 }); await page.goto('/fundus'); await expect(page.getByRole('heading', { name: 'Fundus', exact: true })).toBeVisible(); await expect(page.locator('.sidebar')).toBeVisible(); await expect(page.locator('.mobile-navigation')).toBeHidden(); await expect(page.locator('.utility-navigation a')).toHaveText('Verwaltung'); await expect(page.locator('.piece-card')).toHaveCount(4); const firstRow = await page.locator('.piece-card').nth(0).boundingBox(); const secondRow = await page.locator('.piece-card').nth(1).boundingBox(); expect(firstRow?.x).toBe(secondRow?.x); expect(firstRow?.y).toBeLessThan(secondRow?.y ?? 0); expect(firstRow?.height ?? 999).toBeLessThan(130); const searchBox = await page.locator('.search-field').boundingBox(); const statusBox = await page.getByLabel('Status').boundingBox(); expect(searchBox?.height).toBe(statusBox?.height); await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('Pixelart'); await page.getByRole('button', { name: 'Anwenden' }).click(); await expect(page.locator('.piece-card')).toHaveCount(1); await page.getByLabel('Status').selectOption('preparing'); await page.getByRole('button', { name: 'Anwenden' }).click(); await expect(page.getByRole('heading', { name: 'Kein passendes Piece' })).toBeVisible(); await page.getByRole('link', { name: 'Filter zurücksetzen' }).click(); await expect(page.locator('.piece-card')).toHaveCount(4); await page.getByRole('button', { name: 'Darstellung: System' }).click(); await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light'); await page.reload(); await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light'); await expect(page.getByRole('button', { name: 'Darstellung: Hell' })).toBeVisible(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('fundus-desktop-light.png'), fullPage: true }); }); test('desktop builder keeps the fundus beside the wider run sheet', async ({ page }, testInfo) => { await page.setViewportSize({ width: 1280, height: 900 }); await page.goto('/fundus'); await setTheme(page, 'dark'); await openSeededShow(page); await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); await expect(page.getByRole('heading', { name: 'Herbstsendung: Kleine Welten' })).toBeVisible(); await expect(page.locator('.source-pane')).toBeVisible(); await expect(page.locator('.run-pane')).toBeVisible(); await expect(page.locator('.mobile-pane-switch')).toBeHidden(); await expect( page.locator('.show-summary strong').filter({ hasText: '1 Std. 25 Min.' }) ).toBeVisible(); const sourceBox = await page.locator('.source-pane').boundingBox(); const runBox = await page.locator('.run-pane').boundingBox(); expect(sourceBox?.x).toBeLessThan(runBox?.x ?? 0); expect(sourceBox?.width ?? 9999).toBeLessThan(runBox?.width ?? 0); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('sendungen-desktop-dark.png'), fullPage: true }); }); test('narrow desktop retains the sidebar and switches the builder panes', async ({ page }) => { await page.setViewportSize({ width: 960, height: 850 }); await openSeededShow(page); await expect(page.locator('.sidebar')).toBeVisible(); await expect(page.locator('.mobile-navigation')).toBeHidden(); await expect(page.locator('.mobile-pane-switch')).toBeVisible(); await expect(page.locator('.run-pane')).toBeVisible(); await expect(page.locator('.source-pane')).toBeHidden(); await page.getByRole('button', { name: 'Fundus', exact: true }).click(); await expect(page.locator('.source-pane')).toBeVisible(); await expect(page.locator('.run-pane')).toBeHidden(); await expectNoHorizontalOverflow(page); }); test('mobile and tablet navigation do not obscure switchable content', async ({ page }, testInfo) => { for (const width of [390, 768]) { await page.setViewportSize({ width, height: 900 }); await openSeededShow(page); await expect(page.locator('.sidebar')).toBeHidden(); await expect(page.locator('.mobile-navigation')).toBeVisible(); await expect(page.locator('.run-pane')).toBeVisible(); await page.getByRole('button', { name: 'Fundus', exact: true }).click(); await expect(page.locator('.source-pane')).toBeVisible(); await expect(page.locator('.run-pane')).toBeHidden(); await expectNoHorizontalOverflow(page); await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); const verticalSpace = await page.evaluate(() => { const navigation = document.querySelector('.mobile-navigation')?.getBoundingClientRect(); const content = document.querySelector('.source-pane')?.getBoundingClientRect(); return { contentBottom: content?.bottom ?? 0, navigationTop: navigation?.top ?? 0 }; }); expect(verticalSpace.contentBottom).toBeLessThanOrEqual(verticalSpace.navigationTop + 1); } await page.setViewportSize({ width: 390, height: 844 }); await openSeededShow(page); await page.screenshot({ path: testInfo.outputPath('sendungen-mobile.png'), fullPage: true }); }); test('a show can be planned from a template with independent usages and reserve', async ({ page }, testInfo) => { await page.setViewportSize({ width: 1440, height: 1000 }); await page.goto('/sendungen'); await page.getByLabel('Titel').fill('E2E Planung Spiele und JC'); await page.getByLabel('Ausgangspunkt').selectOption('template-default'); await page.getByRole('button', { name: 'Sendung anlegen' }).click(); await expect(page).toHaveURL(/\/sendungen\/[a-f0-9-]+$/); const showUrl = page.url(); await expect(page.locator('.run-section')).toHaveCount(5); const firstSection = page.locator('.run-section').first(); const sectionTransfer = await page.evaluateHandle(() => new DataTransfer()); await firstSection.locator('.section-drag-handle').dispatchEvent('dragstart', { dataTransfer: sectionTransfer }); await page.locator('.run-section').nth(1).dispatchEvent('dragover', { dataTransfer: sectionTransfer }); await expect(page.locator('[data-drop-preview="section"]')).toContainText('Start'); await expect(firstSection).toHaveClass(/is-dragging/); await firstSection.locator('.section-drag-handle').dispatchEvent('dragend', { dataTransfer: sectionTransfer }); await sectionTransfer.dispose(); await expect(page.locator('[data-drop-preview="section"]')).toHaveCount(0); await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole'); await page.getByRole('button', { name: 'Filtern' }).click(); const source = page .locator('.builder-source-card') .filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' }); await expect(page.locator('.builder-source-card')).toHaveCount(1); const reactionSection = page .locator('.run-section') .filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) }); await page.locator('body').evaluate((body) => (body.dataset.builderDocument = 'stable')); const sourceDragHandle = source.locator('.source-drag-copy'); const emptyReactionTarget = reactionSection.locator('.section-placeholder'); const dataTransfer = await page.evaluateHandle(() => new DataTransfer()); await sourceDragHandle.dispatchEvent('dragstart', { dataTransfer }); await emptyReactionTarget.dispatchEvent('dragover', { dataTransfer }); const insertionPreview = reactionSection.locator('[data-drop-preview="usage"]'); await expect(insertionPreview).toContainText('Die kleine Konsole mit dem großen Dreh'); await expect(source).toHaveClass(/is-dragging/); await expect(source).toHaveCSS('opacity', '0.45'); await emptyReactionTarget.dispatchEvent('drop', { dataTransfer }); await sourceDragHandle.dispatchEvent('dragend', { dataTransfer }); await dataTransfer.dispose(); await expect(reactionSection.locator('.editable-usage')).toHaveCount(1); await expect(page.locator('body')).toHaveAttribute('data-builder-document', 'stable'); 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).locator('summary').click(); await usages.nth(0).getByLabel('Titel').fill('Konsole als Auftakt'); await usages.nth(0).getByLabel('Dauer in Minuten').fill('5'); await usages.nth(0).getByLabel('Eigene Notizen').fill('Kurbel zuerst zeigen'); await usages.nth(0).getByRole('button', { name: 'Verwendung speichern' }).click(); await expect(reactionSection).toContainText('Konsole als Auftakt'); usages = page .locator('.run-section') .filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) }) .locator('.editable-usage'); await usages.nth(1).locator('summary').click(); await usages.nth(1).getByLabel('Titel').fill('Konsole als Rückblick'); await usages.nth(1).getByLabel('Dauer in Minuten').fill('15'); await usages.nth(1).getByLabel('Eigene Notizen').fill('Andere Perspektive'); await usages.nth(1).getByRole('button', { name: 'Verwendung speichern' }).click(); await expect(page.locator('.show-summary')).toContainText('2 Std. 25 Min.'); const planTimeBeforeReserve = await page.locator('.show-summary').textContent(); await source.getByLabel(/Ziel für/).selectOption(''); await source.getByRole('button', { name: 'Hinzufügen' }).click(); await expect(page.locator('.reserve-usage')).toHaveCount(1); expect(await page.locator('.show-summary').textContent()).toBe(planTimeBeforeReserve); const moveUp = page.getByRole('button', { name: 'Konsole als Rückblick nach oben' }); 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 reserveHandle.dispatchEvent('dragend', { 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(3); const savedUsage = page.locator('.editable-usage').filter({ hasText: 'Konsole als Auftakt' }); await savedUsage.locator('summary').click(); await expect(savedUsage.getByLabel('Eigene Notizen')).toHaveValue('Kurbel zuerst zeigen'); await page.setViewportSize({ width: 390, height: 844 }); 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'); await expect( page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }) ).toBeVisible(); await page.goto(showUrl); }); test('all work areas fit at required widths in light and dark mode', async ({ page }, testInfo) => { for (const viewport of viewports) { await page.setViewportSize({ width: viewport.width, height: viewport.height }); await page.goto('/fundus'); await setTheme(page, viewport.theme); for (const route of routes) { await page.goto(route); await expect(page.locator('html')).toHaveAttribute('data-theme', viewport.theme); await expectNoHorizontalOverflow(page); } } await page.setViewportSize({ width: 768, height: 900 }); await page.goto('/fundus'); await setTheme(page, 'dark'); await page.goto('/verwaltung'); await page.screenshot({ path: testInfo.outputPath('verwaltung-tablet-dark.png'), fullPage: true }); }); test('long titles and longer fundus lists reflow without overflow', async ({ page }, testInfo) => { await page.setViewportSize({ width: 1280, height: 900 }); await page.goto('/fundus'); await expect(page.locator('.piece-card')).toHaveCount(4); await page.evaluate(() => { const list = document.querySelector('.piece-grid'); const rows = Array.from(document.querySelectorAll('.piece-card')); const title = rows[0]?.querySelector('h2'); if (title) { title.textContent = 'Ein außergewöhnlich langer redaktioneller Titel über kleine Konsolen, große Ideen und unerwartete Wendungen'; } for (let repetition = 0; repetition < 3; repetition += 1) { for (const row of rows) list?.append(row.cloneNode(true)); } }); await expect(page.locator('.piece-card')).toHaveCount(16); await expectNoHorizontalOverflow(page); await page.setViewportSize({ width: 390, height: 844 }); await expectNoHorizontalOverflow(page); await expect(page.locator('.piece-card').first()).toHaveCSS('flex-direction', 'column'); const mobileCard = await page.locator('.piece-card').first().boundingBox(); const mobilePrimary = await page.locator('.piece-primary').first().boundingBox(); expect(mobilePrimary?.width ?? 0).toBeGreaterThan((mobileCard?.width ?? 0) - 40); await expect(page.locator('.piece-card').first().getByRole('heading')).toContainText( 'Ein außergewöhnlich langer redaktioneller Titel' ); await page.screenshot({ path: testInfo.outputPath('fundus-mobile-long-content.png'), fullPage: true }); }); test('inbox, archive and administration stay concise', async ({ page }) => { for (const width of [390, 1280]) { await page.setViewportSize({ width, height: 900 }); await page.goto('/eingang'); await expect(page.locator('.capture-panel')).toBeVisible(); await expect(page.locator('.inbox-item')).toHaveCount(1); await expectNoHorizontalOverflow(page); await page.goto('/archiv'); await expect(page.locator('.archive-list article')).toHaveCount(1); await expectNoHorizontalOverflow(page); } await page.setViewportSize({ width: 1280, height: 900 }); await page.goto('/verwaltung'); const panels = page.locator('.taxonomy-panel'); await expect(panels).toHaveCount(3); const boxes = await Promise.all([ panels.nth(0).boundingBox(), panels.nth(1).boundingBox(), panels.nth(2).boundingBox() ]); expect(boxes[0]?.y).toBe(boxes[1]?.y); expect(boxes[1]?.y).toBe(boxes[2]?.y); }); test('live control persists state and exposes only explicit presentation data', async ({ page, browser }, testInfo) => { await page.setViewportSize({ width: 1280, height: 900 }); await page.goto('/fundus?q=kleine+Konsole'); await page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }).click(); const png = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64' ); await page.getByLabel('Bild', { exact: true }).setInputFiles({ name: 'live-konsole.png', mimeType: 'image/png', buffer: png }); await page.getByLabel('Alternativtext').fill('Kleine Konsole im Live-Bild'); await page.getByRole('button', { name: 'Bild hochladen' }).click(); await page.goto('/live?show=show-october'); await expect(page.getByText('Noch nicht gestartet.')).toBeVisible(); await page.getByRole('button', { name: 'Sendung starten' }).click(); await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh'); await expect( page.getByText('Herstellerangaben gegen unabhängige Tests prüfen') ).not.toBeVisible(); const before = await page .locator('.live-current') .evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size')); await page.getByRole('button', { name: 'Schrift vergrößern' }).click(); await page.reload(); const restored = await page .locator('.live-current') .evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size')); expect(restored).not.toBe(before); await page.getByText('Manuelle Recherche öffnen').click(); await expect(page.locator('.research-details')).toHaveAttribute('open', ''); const [popup] = await Promise.all([ page.waitForEvent('popup'), page.getByRole('button', { name: 'Präsentationsfenster öffnen' }).click() ]); await popup.waitForURL(/\/heinzlive\/[A-Za-z0-9_-]+$/); const outputUrl = popup.url(); const anonymous = await browser.newContext(); const output = await anonymous.newPage(); await output.goto(outputUrl); await expect( output.getByRole('heading', { name: 'Bereit für den nächsten Inhalt' }) ).toBeVisible(); await page.getByRole('button', { name: 'Präsentieren', exact: true }).click(); await expect( output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }) ).toBeVisible({ timeout: 10_000 }); await expect(output.getByText('Weniger Leistung, mehr Idee.')).toBeVisible(); await expect(output.getByAltText('Kleine Konsole im Live-Bild')).toBeVisible(); await output.screenshot({ path: testInfo.outputPath('heinzlive-output.png'), fullPage: true }); const outputHtml = await output.content(); expect(outputHtml).not.toContain('Herstellerangaben'); expect(outputHtml).not.toContain('Erst Formfaktor zeigen'); expect(outputHtml).not.toContain(adminEmail); expect(outputHtml).not.toContain('live-konsole.png'); const outputState = await anonymous.request.get(`${outputUrl}/state`); expect(outputState.status()).toBe(200); const outputJson = await outputState.text(); expect(outputJson).toContain('Weniger Leistung'); expect(outputJson).not.toContain('Herstellerangaben'); expect(outputJson).not.toContain('Moderationsstichpunkte'); expect(outputJson).not.toContain('live-konsole.png'); await page.getByRole('button', { name: 'Als Nächstes' }).click(); await expect(page.locator('.next-card')).toContainText('Pixelart'); await page.getByRole('button', { name: 'Erledigen und weiter' }).click(); await expect(page.locator('.live-current')).toContainText('Pixelart'); await page.getByRole('button', { name: 'Überspringen' }).click(); await page.getByRole('button', { name: 'Zurück' }).click(); await expect(page.locator('.live-current')).toContainText('Pixelart'); await output.route('**/state', (route) => route.abort()); await expect(output.getByText('Verbindung unterbrochen')).toBeVisible({ timeout: 5_000 }); await output.unroute('**/state'); await expect(output.getByText('Verbunden')).toBeVisible({ timeout: 5_000 }); await page.setViewportSize({ width: 390, height: 844 }); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('live-controller-mobile.png'), fullPage: true }); await page.setViewportSize({ width: 1280, height: 900 }); await page.evaluate(() => { window.open = () => null; }); await page.getByRole('button', { name: 'Präsentationsfenster öffnen' }).click(); await expect( page.getByText('Popup blockiert. Bitte Popups erlauben und erneut öffnen.') ).toBeVisible(); await anonymous.close(); await popup.close(); await expectNoHorizontalOverflow(page); }); test('pieces autosave safely, detect conflicts and protect uploaded images', async ({ page, browser }, testInfo) => { await page.setViewportSize({ width: 1280, height: 1000 }); await page.goto('/fundus'); await page.getByText('Neues Piece anlegen').click(); await page.getByRole('button', { name: /Reaction \/ Fundstück/ }).click(); await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/); const pieceUrl = page.url(); await page.getByLabel('Titel').fill('E2E Fundstück mit Markdown'); await page.getByRole('link', { name: '← Fundus', exact: true }).click(); await expect(page).toHaveURL(/\/fundus$/); await page.getByRole('heading', { name: 'E2E Fundstück mit Markdown' }).click(); await expect(page).toHaveURL(pieceUrl); await page .getByLabel('Kurzbeschreibung') .fill('Persistiert nach Neuladen und ist gezielt suchbar.'); await page.getByLabel('Status').selectOption('ready'); await page.getByLabel('Dauer in Minuten').fill('10.25'); await page.getByLabel('Gaming').check(); await page.getByLabel('Private Recherche').fill('Nur intern: **Hintergrund prüfen**.'); await page .getByLabel('Präsentationstext') .fill('## Sichere Vorschau\n\n **sichtbar**'); await page.getByRole('button', { name: 'Quelle hinzufügen' }).click(); const source = page.locator('.source-row').last(); await source.getByLabel('Name').fill('Originalbeitrag'); await source.getByLabel('URL').fill('https://example.com/e2e-fundstueck'); await source.getByLabel('Zeitmarke in Sekunden').fill('42'); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await page.waitForTimeout(1_200); await expect(page.locator('.save-indicator')).toContainText('Gespeichert'); await expect(page.locator('.markdown-preview script')).toHaveCount(0); await expect(page.locator('.markdown-preview').last()).toContainText('Sichere Vorschau'); await page.reload(); await expect(page.getByLabel('Titel')).toHaveValue('E2E Fundstück mit Markdown'); await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25'); await expect(page.getByLabel('Private Recherche')).toHaveValue( 'Nur intern: **Hintergrund prüfen**.' ); const png = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64' ); await page.getByLabel('Bild', { exact: true }).setInputFiles({ name: 'redaktionsbild.png', mimeType: 'image/png', buffer: png }); await page.getByLabel('Alternativtext').fill('Ein Testpixel'); await page.getByRole('button', { name: 'Bild hochladen' }).click(); const image = page.locator('.media-grid img'); await expect(image).toHaveCount(1); await expect(image).toHaveAttribute('alt', 'Ein Testpixel'); const mediaUrl = await image.getAttribute('src'); const anonymous = await browser.newContext(); const anonymousResponse = await anonymous.request.get(new URL(mediaUrl!, page.url()).toString(), { maxRedirects: 0 }); expect(anonymousResponse.status()).toBe(303); await anonymous.close(); const concurrent = await page.context().newPage(); await concurrent.goto(pieceUrl); await page.getByLabel('Titel').fill('E2E Fundstück, extern aktualisiert'); await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 }); await concurrent .getByLabel('Kurzbeschreibung') .fill('Diese veraltete Änderung darf nicht gewinnen.'); await expect(concurrent.locator('.save-indicator')).toContainText('neuere Änderung', { timeout: 10_000 }); await concurrent.reload(); await expect(concurrent.getByLabel('Titel')).toHaveValue('E2E Fundstück, extern aktualisiert'); await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue( 'Diese veraltete Änderung darf nicht gewinnen.' ); await concurrent.close(); await page.goto('/fundus?q=extern+aktualisiert'); await expect(page.locator('.piece-card')).toHaveCount(1); await expect( page.getByRole('heading', { name: 'E2E Fundstück, extern aktualisiert' }) ).toBeVisible(); await page.getByRole('link', { name: 'Bearbeiten' }).click(); await page.getByRole('button', { name: 'Piece archivieren' }).click(); await expect(page).toHaveURL(/\/archiv$/); const archived = page.locator('.archive-list article').filter({ hasText: 'extern aktualisiert' }); await expect(archived).toBeVisible(); await archived.getByRole('button', { name: 'Wiederherstellen' }).click(); await expect(archived).toHaveCount(0); await page.goto('/fundus?q=extern+aktualisiert'); await expect(page.locator('.piece-card')).toHaveCount(1); await page.locator('.piece-card').getByRole('button', { name: 'Duplizieren' }).click(); await expect(page.getByLabel('Titel')).toHaveValue( 'Kopie von E2E Fundstück, extern aktualisiert' ); await page.setViewportSize({ width: 390, height: 844 }); await setTheme(page, 'dark'); await page.reload(); await expectNoHorizontalOverflow(page); await page.screenshot({ path: testInfo.outputPath('piece-editor-mobile-dark.png'), fullPage: true }); });