Files
sendewerk/tests/ui.e2e.ts
T

1790 lines
79 KiB
TypeScript

import { expect, test, type Locator, type Page } from '@playwright/test';
import Database from 'better-sqlite3';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
const adminEmail = 'barkeeper@example.test';
const adminPassword = 'Nur-fuer-E2E-Admin-1234';
const crewEmail = 'crew@example.test';
const crewPassword = 'Nur-fuer-E2E-Crew-1234';
const routes = ['/fundus', '/sendungen', '/eingang', '/archiv', '/verwaltung', '/live'];
const viewports = [
{ width: 390, height: 844, theme: 'light' },
{ width: 390, height: 844, theme: 'dark' },
{ width: 768, height: 900, theme: 'light' },
{ width: 768, height: 900, theme: 'dark' },
{ width: 1440, height: 1000, theme: 'dark' },
{ width: 1440, height: 1000, theme: 'light' }
] as const;
async function expectNoHorizontalOverflow(page: Page) {
const dimensions = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth
}));
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
}
async function setTheme(page: Page, theme: 'light' | 'dark') {
await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme);
}
async function openEditorialField(page: Page, heading: string) {
if (heading.includes('Recherche')) {
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
} else if (heading.includes('Präsentation')) {
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
} else {
await page.getByRole('button', { name: 'Sendungstext', exact: true }).click();
}
}
async function editInlineTitle(scope: Page | Locator, title: string, value: string) {
await scope.getByRole('button', { name: `${title} Titel bearbeiten` }).click();
await scope.getByLabel('Titel', { exact: true }).fill(value);
await scope.getByLabel('Titel', { exact: true }).press('Enter');
}
async function login(page: Page, email = adminEmail, password = adminPassword) {
await page.goto('/login');
await page.getByLabel('E-Mail').fill(email);
await page.getByLabel('Passwort', { exact: true }).fill(password);
await page.getByRole('button', { name: 'Anmelden' }).click();
await expect(page).toHaveURL(/\/fundus$/);
}
async function openSeededShow(page: Page) {
await page.goto('/sendungen');
await page.getByRole('link', { name: /Herbstsendung: Kleine Welten/ }).click();
await expect(page).toHaveURL(/\/sendungen\/show-october$/);
}
function latestMailLink(subject: string): string {
const outbox = process.env.SENDEWERK_E2E_OUTBOX_DIR;
if (!outbox) throw new Error('E2E-Outbox ist nicht konfiguriert.');
const messages = readdirSync(outbox)
.sort()
.map(
(file) =>
JSON.parse(readFileSync(path.join(outbox, file), 'utf8')) as {
subject: string;
text: string;
}
)
.filter((message) => message.subject === subject);
const match = messages.at(-1)?.text.match(/https?:\/\/[^\s]+/);
if (!match) throw new Error(`Keine Testmail mit Betreff ${subject} gefunden.`);
return match[0];
}
function addSentHistoryFixture(pieceId: string) {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
const insertShow = database.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status,
revision, created_at_utc, updated_at_utc) VALUES (?, ?, ?, '', '', 'completed', 1, ?, ?)`
);
const insertSection = database.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, 'Ablauf', NULL, 0, NULL, 0)`
);
const insertUsage = database.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot, moderation_snapshot,
transition_snapshot, presentation_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, 0, 'done', 300, 'Historische Verwendung', '', '', '', '', '', '', 0)`
);
database.transaction(() => {
for (const [suffix, title, date] of [
['older', 'Rückblick im September', '2026-09-10T18:00:00.000Z'],
['latest', 'Rückblick im Oktober', '2026-10-10T18:00:00.000Z']
] as const) {
const showId = `e2e-history-${suffix}`;
const sectionId = `e2e-history-section-${suffix}`;
insertShow.run(showId, title, date, date, date);
insertSection.run(sectionId, showId);
insertUsage.run(`e2e-history-usage-${suffix}`, sectionId, pieceId);
}
})();
database.close();
}
function removeSentHistoryFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) return;
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
database.prepare("DELETE FROM shows WHERE id LIKE 'e2e-history-%'").run();
database.close();
}
function addPresentationFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
const now = '2026-10-22T19:00:00.000Z';
database.transaction(() => {
database
.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status,
revision, created_at_utc, updated_at_utc)
VALUES ('e2e-presentation', 'E2E Präsentation', ?, '', '', 'ready', 1, ?, ?)`
)
.run(now, now, now);
database
.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank)
VALUES ('e2e-presentation-section', 'e2e-presentation', 'Player', NULL, 0, NULL, 0),
('e2e-presentation-reserve', 'e2e-presentation', 'Reservebank', NULL, 1, NULL, 1)`
)
.run();
const insert = database.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot, moderation_snapshot,
transition_snapshot, presentation_snapshot, own_notes, is_reserve,
presentation_mode_snapshot, presentation_source_label_snapshot,
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot)
VALUES (?, 'e2e-presentation-section', NULL, ?, 'open', 300, ?, '', ?, ?, '', '', '', 0,
'video', ?, ?, ?, ?, '', '', ?, ?, ?)`
);
insert.run(
'e2e-youtube',
0,
'YouTube Kurzlink',
'PRIVATE_RESEARCH_YOUTUBE',
'PRIVATE_MODERATION_YOUTUBE',
'YouTube',
'https://youtu.be/dQw4w9WgXcQ',
75,
'YouTube Vorschau',
'YouTube',
'youtube',
'dQw4w9WgXcQ'
);
insert.run(
'e2e-tiktok',
1,
'TikTok Hochformat',
'PRIVATE_RESEARCH_TIKTOK',
'PRIVATE_MODERATION_TIKTOK',
'TikTok',
'https://www.tiktok.com/@scout2015/video/6718335390845095173',
12,
'TikTok Vorschau',
'TikTok',
'tiktok',
'6718335390845095173'
);
})();
database.close();
}
function removePresentationFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) return;
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
database.prepare("DELETE FROM shows WHERE id = 'e2e-presentation'").run();
database.close();
}
function addCalendarFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
const insert = database.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status,
revision, created_at_utc, updated_at_utc) VALUES (?, ?, ?, '', '', 'draft', 1, ?, ?)`
);
const now = '2026-10-24T10:00:00.000Z';
database.transaction(() => {
insert.run('e2e-calendar-late', 'Kalender Spättermin', '2026-10-24T18:00:00.000Z', now, now);
insert.run('e2e-calendar-early', 'Kalender Frühtermin', '2026-10-24T08:00:00.000Z', now, now);
insert.run(
'e2e-calendar-next',
'Kalender Novembertermin',
'2026-11-03T17:00:00.000Z',
now,
now
);
insert.run('e2e-calendar-undated', 'Kalender ohne Termin', null, now, now);
})();
database.close();
}
function removeCalendarFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) return;
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.prepare("DELETE FROM shows WHERE id LIKE 'e2e-calendar-%'").run();
database.close();
}
function setSectionDuration(sectionId: string, seconds: number | null) {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database
.prepare('UPDATE show_sections SET explicit_duration_seconds = ? WHERE id = ?')
.run(seconds, sectionId);
database.close();
}
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
await page.goto('/');
if (await page.getByRole('heading', { name: 'Ersten Barkeeper anlegen' }).isVisible()) {
await page.getByLabel('Betreiber-Setup-Token').fill('e2e-only-setup-token-32-characters');
await page.getByLabel('Name').fill('E2E Barkeeper');
await page.getByLabel('E-Mail').fill(adminEmail);
await page.getByLabel('Passwort').fill(adminPassword);
await page.getByRole('button', { name: 'Barkeeper anlegen' }).click();
} else {
await page.getByLabel('E-Mail').fill(adminEmail);
await page.getByLabel('Passwort').fill(adminPassword);
await page.getByRole('button', { name: 'Anmelden' }).click();
}
await expect(page).toHaveURL(/\/fundus$/);
const sessionCookie = (await page.context().cookies()).find(
(cookie) => cookie.name === 'sendewerk_session'
);
expect(sessionCookie?.httpOnly).toBe(true);
expect(sessionCookie?.secure).toBe(false);
expect(sessionCookie?.sameSite).toBe('Lax');
await page.goto('/setup');
await expect(page).toHaveURL(/\/fundus$/);
await page.getByRole('button', { name: 'Abmelden', exact: true }).click();
await expect(page).toHaveURL(/\/login$/);
await page.close();
});
test.beforeEach(async ({ page }) => {
await login(page);
});
test('invitations, direct authorization, locking and password reset work end to end', async ({
page,
browser
}, testInfo) => {
await page.goto('/verwaltung');
await page.screenshot({
path: testInfo.outputPath('verwaltung-konten-desktop.png'),
fullPage: true
});
const crossSite = await page.request.post('/verwaltung?/invite', {
form: { email: 'cross-site@example.test', role: 'barkeeper' },
headers: { origin: 'https://example.invalid' }
});
expect(crossSite.status()).toBe(403);
const inviteForm = page.locator('.invite-panel').filter({ hasText: 'Neues Konto einladen' });
await inviteForm.getByLabel('E-Mail').fill(crewEmail);
await inviteForm.getByRole('button', { name: 'Einladung erstellen' }).click();
await expect(
page.getByText('Einladung wurde in der lokalen Mail-Outbox abgelegt.')
).toBeVisible();
const crewContext = await browser.newContext();
const crewPage = await crewContext.newPage();
await crewPage.goto(latestMailLink('Einladung zu Sendewerk'));
await expect(crewPage).toHaveURL(/\/einladung$/);
await crewPage.getByLabel('Name').fill('E2E Crew');
await crewPage.getByLabel('Passwort').fill(crewPassword);
await crewPage.getByRole('button', { name: 'Konto aktivieren' }).click();
await expect(crewPage).toHaveURL(/\/fundus$/);
await expect(crewPage.getByText('Verwaltung', { exact: true })).toHaveCount(0);
const forbidden = await crewPage.request.post('/verwaltung?/invite', {
form: { email: 'verboten@example.test', role: 'barkeeper' },
headers: { origin: 'http://127.0.0.1:4173' }
});
expect(forbidden.status()).toBe(403);
await page.reload();
const crewRow = page.locator('.account-row').filter({ hasText: crewEmail });
await crewRow.getByRole('button', { name: 'Sperren' }).click();
await crewPage.goto('/fundus');
await expect(crewPage).toHaveURL(/\/login$/);
await page.reload();
await page
.locator('.account-row')
.filter({ hasText: crewEmail })
.getByRole('button', { name: 'Entsperren' })
.click();
await crewPage.goto('/passwort-vergessen');
await crewPage.getByLabel('E-Mail').fill(crewEmail);
await crewPage.getByRole('button', { name: 'Reset-Link anfordern' }).click();
await expect(crewPage.getByText('Anfrage verarbeitet.')).toBeVisible();
await crewPage.goto(latestMailLink('Sendewerk-Passwort zurücksetzen'));
await expect(crewPage).toHaveURL(/\/passwort-zuruecksetzen$/);
await crewPage.getByLabel('Neues Passwort').fill('Neues-E2E-Crew-Passwort-1234');
await crewPage.getByRole('button', { name: 'Passwort speichern' }).click();
await login(crewPage, crewEmail, 'Neues-E2E-Crew-Passwort-1234');
await crewContext.close();
const anonymousContext = await browser.newContext({ viewport: { width: 390, height: 844 } });
const loginPage = await anonymousContext.newPage();
await loginPage.goto('/login');
await setTheme(loginPage, 'dark');
await loginPage.reload();
await expectNoHorizontalOverflow(loginPage);
await loginPage.screenshot({
path: testInfo.outputPath('login-mobile-dark.png'),
fullPage: true
});
await anonymousContext.close();
});
test('desktop fundus is a compact, filterable list with persistent themes', async ({
page
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/fundus');
await expect(page.getByRole('heading', { name: 'Fundus', exact: true })).toBeVisible();
await expect(page.locator('.sidebar')).toBeVisible();
await expect(page.locator('.mobile-navigation')).toBeHidden();
await expect(page.locator('.utility-navigation a')).toHaveText('Verwaltung');
await expect(page.locator('.piece-card')).toHaveCount(1);
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await page.getByLabel('Auch eingeplante Inhalte zeigen').check();
await expect(page.locator('.piece-card')).toHaveCount(4);
await page
.getByRole('button', { name: 'Die kleine Konsole mit dem großen Dreh als Favorit markieren' })
.click();
await expect(
page.getByRole('button', {
name: 'Die kleine Konsole mit dem großen Dreh aus Favoriten entfernen'
})
).toBeVisible();
await expect(page.locator('.piece-card').first()).toContainText(
'Die kleine Konsole mit dem großen Dreh'
);
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
const firstRow = await page.locator('.piece-card').nth(0).boundingBox();
const secondRow = await page.locator('.piece-card').nth(1).boundingBox();
expect(firstRow?.x).toBe(secondRow?.x);
expect(firstRow?.y).toBeLessThan(secondRow?.y ?? 0);
expect(firstRow?.height ?? 999).toBeLessThan(130);
const searchBox = await page.locator('.search-field').boundingBox();
const statusBox = await page.getByLabel('Status').boundingBox();
expect(searchBox?.height).toBe(statusBox?.height);
await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('Pixelart');
await page.getByRole('button', { name: 'Filter anwenden' }).click();
await expect(page.locator('.piece-card')).toHaveCount(1);
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await page.getByLabel('Status').selectOption('preparing');
await page.getByRole('button', { name: 'Filter anwenden' }).click();
await expect(page.getByRole('heading', { name: 'Kein passendes Piece' })).toBeVisible();
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await page.getByRole('button', { name: 'Zurücksetzen' }).click();
await expect(page.locator('.piece-card')).toHaveCount(1);
await page.getByRole('button', { name: 'Darstellung: System' }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light');
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light');
await expect(page.getByRole('button', { name: 'Darstellung: Hell' })).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('fundus-desktop-light.png'), fullPage: true });
});
test('sent and planned source filters stay independent and removing the last usage offers the piece again', async ({
page
}, testInfo) => {
addSentHistoryFixture('piece-effects');
try {
await page.goto('/fundus');
await expect(
page.getByRole('heading', { name: 'Warum praktische Effekte wieder auffallen' })
).toHaveCount(0);
await page.evaluate(() => (document.body.dataset.filterDocument = 'stable'));
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await page.getByLabel('Auch verwendete Inhalte zeigen').check();
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await expect(
page.getByRole('heading', { name: 'Warum praktische Effekte wieder auffallen' })
).toHaveCount(0);
await page.getByLabel('Auch eingeplante Inhalte zeigen').check();
const usedPiece = page
.locator('.piece-card')
.filter({ hasText: 'Warum praktische Effekte wieder auffallen' });
await expect(usedPiece).toBeVisible();
await expect(usedPiece).toContainText('Gesendet am 10.10.2026');
await expect(usedPiece).toContainText('+ 1 weitere');
await expect(usedPiece.getByRole('link', { name: 'Rückblick im Oktober' })).toHaveAttribute(
'href',
'/sendungen/e2e-history-latest'
);
await usedPiece.getByText('Vollständige Verwendungshistorie').click();
await expect(usedPiece.locator('.piece-usage-history li')).toHaveCount(2);
await expect(page.locator('body')).toHaveAttribute('data-filter-document', 'stable');
await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('praktische Effekte');
await page.getByRole('button', { name: 'Filter anwenden' }).click();
await expect(page.getByLabel('Auch verwendete Inhalte zeigen')).toBeChecked();
await expect(usedPiece).toBeVisible();
await page.screenshot({
path: testInfo.outputPath('fundus-verwendungshistorie-desktop.png'),
fullPage: true
});
await page.goto('/sendungen');
await page.getByText('Neue Sendung planen', { exact: true }).click();
await page.getByLabel('Titel', { exact: true }).fill('E2E Filterprüfung');
await page.getByRole('radio', { name: /Standardablauf/ }).check();
await page.getByRole('button', { name: 'Sendung anlegen' }).click();
await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole');
await page.getByRole('button', { name: 'Filtern' }).click();
const consolePiece = page
.locator('.builder-source-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' });
await consolePiece.getByRole('button', { name: 'Hinzufügen' }).click();
await expect(consolePiece).toHaveCount(0);
await page.getByPlaceholder('Fundus durchsuchen').fill('');
await page.getByRole('button', { name: 'Filtern' }).click();
await page.getByLabel('Auch eingeplante Inhalte zeigen').check();
await expect(
page
.locator('.builder-source-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
).toContainText('Eingeplant');
await expect(
page
.locator('.builder-source-card')
.filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
).toHaveCount(0);
await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole');
await page.getByRole('button', { name: 'Filtern' }).click();
await expect(page.getByLabel('Auch eingeplante Inhalte zeigen')).toBeChecked();
await expect(
page
.locator('.builder-source-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
).toContainText('Eingeplant');
await page.getByPlaceholder('Fundus durchsuchen').fill('');
await page.getByRole('button', { name: 'Filtern' }).click();
await page.getByLabel('Auch eingeplante Inhalte zeigen').uncheck();
await page.getByLabel('Auch verwendete Inhalte zeigen').check();
await expect(
page
.locator('.builder-source-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
).toHaveCount(0);
await expect(
page
.locator('.builder-source-card')
.filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
).toContainText('Rückblick im Oktober');
await page.screenshot({
path: testInfo.outputPath('sendungsfilter-desktop.png'),
fullPage: true
});
page.once('dialog', (dialog) => dialog.accept());
await page
.locator('.reserve-usage')
.getByRole('button', { name: /aus Sendung entfernen/ })
.click();
await page.getByLabel('Auch verwendete Inhalte zeigen').uncheck();
await expect(
page
.locator('.builder-source-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: 'Fundus', exact: true }).click();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('sendungsfilter-mobile.png'),
fullPage: true
});
} finally {
removeSentHistoryFixture();
}
});
test('show overview switches persistently between list, calendar and mobile agenda', async ({
page
}, testInfo) => {
addCalendarFixture();
try {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/sendungen?month=2026-10');
await page.getByRole('button', { name: 'Kalender' }).click();
await expect(page.getByRole('heading', { name: 'Oktober 2026' })).toBeVisible();
const octoberDay = page
.locator('.show-calendar-day')
.filter({ hasText: 'Kalender Frühtermin' });
await expect(octoberDay).toHaveClass(/today/);
await expect(octoberDay).toContainText('Kalender Spättermin');
await expect(octoberDay.locator('.show-calendar-event')).toHaveCount(2);
await expect(octoberDay.locator('.show-calendar-event').first()).toContainText('10:00');
await expect(page.locator('.show-undated')).toContainText('Kalender ohne Termin');
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('sendungen-kalender-desktop.png'),
fullPage: true
});
await page.getByRole('link', { name: 'Nächster Monat' }).click();
await expect(page.getByRole('heading', { name: 'November 2026' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Kalender Novembertermin' })).toBeVisible();
await page.reload();
await expect(page.getByRole('button', { name: 'Kalender' })).toHaveAttribute(
'aria-pressed',
'true'
);
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('link', { name: 'Vorheriger Monat' }).click();
await expect(page.locator('.show-calendar')).toBeHidden();
await expect(page.locator('.show-agenda')).toContainText('Kalender Frühtermin');
await expect(page.locator('.show-agenda')).toContainText('Kalender Spättermin');
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('sendungen-kalender-mobile.png'),
fullPage: true
});
await page.getByRole('button', { name: 'Liste' }).click();
await expect(page.getByRole('heading', { name: 'Geplante Sendungen' })).toBeVisible();
} finally {
removeCalendarFixture();
}
});
test('desktop builder keeps the fundus beside the wider run sheet', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus');
await setTheme(page, 'dark');
await openSeededShow(page);
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(page.getByRole('heading', { name: 'Herbstsendung: Kleine Welten' })).toBeVisible();
await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible();
await expect(page.locator('.mobile-pane-switch')).toBeHidden();
await expect(
page.locator('.show-summary strong').filter({ hasText: '1 Std. 25 Min.' })
).toBeVisible();
const sourceBox = await page.locator('.source-pane').boundingBox();
const runBox = await page.locator('.run-pane').boundingBox();
expect(sourceBox?.x).toBeLessThan(runBox?.x ?? 0);
expect(sourceBox?.width ?? 9999).toBeLessThan(runBox?.width ?? 0);
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('sendungen-desktop-dark.png'),
fullPage: true
});
});
test('narrow desktop retains the sidebar and switches the builder panes', async ({ page }) => {
await page.setViewportSize({ width: 960, height: 850 });
await openSeededShow(page);
await expect(page.locator('.sidebar')).toBeVisible();
await expect(page.locator('.mobile-navigation')).toBeHidden();
await expect(page.locator('.mobile-pane-switch')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible();
await expect(page.locator('.source-pane')).toBeHidden();
await page.getByRole('button', { name: 'Fundus', exact: true }).click();
await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeHidden();
await expectNoHorizontalOverflow(page);
});
test('mobile and tablet navigation do not obscure switchable content', async ({
page
}, testInfo) => {
for (const width of [390, 768]) {
await page.setViewportSize({ width, height: 900 });
await openSeededShow(page);
await expect(page.locator('.sidebar')).toBeHidden();
await expect(page.locator('.mobile-navigation')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible();
await page.getByRole('button', { name: 'Fundus', exact: true }).click();
await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeHidden();
await expectNoHorizontalOverflow(page);
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
const verticalSpace = await page.evaluate(() => {
const navigation = document.querySelector('.mobile-navigation')?.getBoundingClientRect();
const content = document.querySelector('.source-pane')?.getBoundingClientRect();
return { contentBottom: content?.bottom ?? 0, navigationTop: navigation?.top ?? 0 };
});
expect(verticalSpace.contentBottom).toBeLessThanOrEqual(verticalSpace.navigationTop + 1);
}
await page.setViewportSize({ width: 390, height: 844 });
await openSeededShow(page);
await page.screenshot({ path: testInfo.outputPath('sendungen-mobile.png'), fullPage: true });
});
test('a show can be planned from a template with independent usages and reserve', async ({
page
}, testInfo) => {
test.slow();
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/sendungen');
await page.getByText('Neue Sendung planen', { exact: true }).click();
await page.getByLabel('Titel', { exact: true }).fill('E2E Planung Spiele und JC');
await page.getByRole('radio', { name: /Standardablauf/ }).check();
await page.getByRole('button', { name: 'Sendung anlegen' }).click();
await expect(page).toHaveURL(/\/sendungen\/[a-f0-9-]+$/);
const showUrl = page.url();
await expect(page.locator('.run-section')).toHaveCount(5);
const firstSection = page.locator('.run-section').first();
const sectionTransfer = await page.evaluateHandle(() => new DataTransfer());
await firstSection.locator('.section-drag-handle').dispatchEvent('dragstart', {
dataTransfer: sectionTransfer
});
await page.locator('.run-section').nth(1).dispatchEvent('dragover', {
dataTransfer: sectionTransfer
});
await expect(page.locator('[data-drop-preview="section"]')).toContainText('Start');
await expect(firstSection).toHaveClass(/is-dragging/);
await firstSection.locator('.section-drag-handle').dispatchEvent('dragend', {
dataTransfer: sectionTransfer
});
await sectionTransfer.dispose();
await expect(page.locator('[data-drop-preview="section"]')).toHaveCount(0);
await page.getByPlaceholder('Fundus durchsuchen').fill('kleine Konsole');
await page.getByRole('button', { name: 'Filtern' }).click();
const source = page
.locator('.builder-source-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' });
await expect(page.locator('.builder-source-card')).toHaveCount(1);
const reactionSection = page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) });
await page.locator('body').evaluate((body) => (body.dataset.builderDocument = 'stable'));
const sourceDragHandle = source.locator('.source-drag-copy');
const emptyReactionTarget = reactionSection.locator('.section-placeholder');
const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
await sourceDragHandle.dispatchEvent('dragstart', { dataTransfer });
await emptyReactionTarget.dispatchEvent('dragover', { dataTransfer });
const insertionPreview = reactionSection.locator('[data-drop-preview="usage"]');
await expect(insertionPreview).toContainText('Die kleine Konsole mit dem großen Dreh');
await expect(source).toHaveClass(/is-dragging/);
await expect(source).toHaveCSS('opacity', '0.45');
await emptyReactionTarget.dispatchEvent('drop', { dataTransfer });
await dataTransfer.dispose();
await expect(reactionSection.locator('.editable-usage')).toHaveCount(1);
await expect(page.locator('body')).toHaveAttribute('data-builder-document', 'stable');
await expect(source).toHaveCount(0);
await page.getByLabel('Auch eingeplante Inhalte zeigen').check();
await expect(source).toContainText('Eingeplant');
await sourceDragHandle.dragTo(reactionSection.locator('.editable-usage'));
let usages = page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) })
.locator('.editable-usage');
await expect(usages).toHaveCount(2);
await usages
.nth(0)
.getByRole('button', {
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
})
.click();
await expect(page.locator('dialog[open]')).toContainText('Sendungsfassung');
await expect(page.locator('dialog[open] .piece-dialog-workspace')).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('verwendungseditor-desktop.png'),
fullPage: true
});
await page
.locator('dialog[open]')
.getByRole('button', { name: 'Aus Fundus aktualisieren' })
.click();
await expect(page.locator('dialog[open]')).toHaveCount(0);
usages = page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) })
.locator('.editable-usage');
await usages
.nth(0)
.getByRole('button', {
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
})
.click();
await editInlineTitle(
usages.nth(0),
'Die kleine Konsole mit dem großen Dreh',
'Konsole als Auftakt'
);
await usages.nth(0).locator('.piece-meta-details > summary').click();
await usages.nth(0).getByLabel('Dauer in Minuten').fill('5');
await usages.nth(0).getByRole('button', { name: 'Recherche & Quellen' }).click();
await usages
.nth(0)
.getByRole('textbox', { name: 'Interne Notizen zu dieser Verwendung', exact: true })
.fill('Kurbel zuerst zeigen');
await usages.nth(0).getByRole('button', { name: 'Verwendung speichern' }).click();
await expect(reactionSection).toContainText('Konsole als Auftakt');
usages = page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) })
.locator('.editable-usage');
await usages
.nth(1)
.getByRole('button', {
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
})
.click();
await editInlineTitle(
usages.nth(1),
'Die kleine Konsole mit dem großen Dreh',
'Konsole als Rückblick'
);
await usages.nth(1).locator('.piece-meta-details > summary').click();
await usages.nth(1).getByLabel('Dauer in Minuten').fill('15');
await usages.nth(1).getByRole('button', { name: 'Recherche & Quellen' }).click();
await usages
.nth(1)
.getByRole('textbox', { name: 'Interne Notizen zu dieser Verwendung', exact: true })
.fill('Andere Perspektive');
await usages.nth(1).getByRole('button', { name: 'Verwendung speichern' }).click();
await expect(page.locator('.show-summary')).toContainText('2 Std. 25 Min.');
usages = page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) })
.locator('.editable-usage');
await usages
.nth(1)
.getByRole('button', { name: 'Konsole als Rückblick bearbeiten und verschieben' })
.click();
await usages.nth(1).getByRole('button', { name: 'Duplizieren' }).click();
await expect(reactionSection.locator('.editable-usage')).toHaveCount(3);
await expect(reactionSection.locator('.editable-usage').nth(2)).toContainText(
'Konsole als Rückblick'
);
await expect(page.locator('.show-summary')).toContainText('2 Std. 40 Min.');
const planTimeBeforeReserve = await page.locator('.show-summary').textContent();
await source.getByLabel(/Ziel für/).selectOption('');
await source.getByRole('button', { name: 'Hinzufügen' }).click();
await expect(page.locator('.reserve-usage')).toHaveCount(1);
expect(await page.locator('.show-summary').textContent()).toBe(planTimeBeforeReserve);
await page
.locator('.reserve-usage')
.getByRole('button', {
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
})
.click();
await expect(page.locator('dialog[open]')).toContainText('Sendungsfassung');
await expect(
page.locator('dialog[open]').getByRole('button', { name: 'Duplizieren' })
).toBeVisible();
await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click();
const moveUp = page.getByRole('button', { name: 'Konsole als Rückblick nach oben' }).first();
await moveUp.focus();
await page.keyboard.press('Enter');
await expect(
page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) })
.locator('.editable-usage strong')
.first()
).toHaveText('Konsole als Rückblick');
const reserveUsage = page.locator('.reserve-usage');
const reserveHandle = reserveUsage.locator('.usage-drag-copy');
const finalSectionTarget = page.locator('.run-section').last().locator('.section-placeholder');
const reserveTransfer = await page.evaluateHandle(() => new DataTransfer());
await reserveHandle.dispatchEvent('dragstart', { dataTransfer: reserveTransfer });
await finalSectionTarget.dispatchEvent('dragover', { dataTransfer: reserveTransfer });
await expect(page.locator('[data-drop-preview="usage"]')).toContainText(
'Die kleine Konsole mit dem großen Dreh'
);
await expect(reserveUsage).toHaveClass(/is-dragging/);
await finalSectionTarget.dispatchEvent('drop', { dataTransfer: reserveTransfer });
await reserveTransfer.dispose();
await expect(page.locator('.reserve-usage')).toHaveCount(0);
await expect(page.locator('body')).toHaveAttribute('data-builder-document', 'stable');
await page.reload();
await expect(page.locator('.editable-usage')).toHaveCount(4);
const savedUsage = page.locator('.editable-usage').filter({ hasText: 'Konsole als Auftakt' });
await savedUsage
.getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' })
.click();
await expect(savedUsage.locator('textarea[name="ownNotes"]')).toHaveValue('Kurbel zuerst zeigen');
await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click();
const duplicatedUsage = page
.locator('.editable-usage')
.filter({ hasText: 'Konsole als Rückblick' });
await expect(duplicatedUsage).toHaveCount(2);
await duplicatedUsage
.nth(1)
.getByRole('button', { name: 'Konsole als Rückblick bearbeiten und verschieben' })
.click();
await expect(duplicatedUsage.nth(1).getByLabel('Dauer in Minuten')).toHaveValue('15');
await expect(duplicatedUsage.nth(1).locator('textarea[name="ownNotes"]')).toHaveValue(
'Andere Perspektive'
);
await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click();
await page.setViewportSize({ width: 390, height: 844 });
await savedUsage
.getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' })
.click();
await expect(page.locator('dialog[open] .piece-dialog-workspace')).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('verwendungseditor-mobile.png'),
fullPage: true
});
await page
.locator('dialog[open]')
.getByRole('button', { name: 'Aus Sendung entfernen' })
.scrollIntoViewIfNeeded();
await expect(
page.locator('dialog[open]').getByRole('button', { name: 'Aus Sendung entfernen' })
).toBeVisible();
await page
.locator('dialog[open] form[action="?/updateUsage"] input[name="revision"]')
.evaluate((element) => ((element as HTMLInputElement).value = '1'));
await page.locator('dialog[open]').getByRole('button', { name: 'Verwendung speichern' }).click();
await expect(page.getByRole('alert')).toContainText('inzwischen geändert');
await page.locator('dialog[open]').getByRole('button', { name: 'Editor schließen' }).click();
await page.getByRole('button', { name: 'Ablauf', exact: true }).click();
await page.getByPlaceholder('Neuer Abschnitt').fill('Finales Spiel');
await page.getByRole('button', { name: 'Abschnitt hinzufügen' }).click();
await expect(page.getByRole('heading', { name: 'Finales Spiel' })).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('sendungsbau-mobile.png'), fullPage: true });
await page.goto('/fundus?q=kleine+Konsole&includePlanned=1');
await expect(
page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible();
await page.goto(showUrl);
});
test('all work areas fit at required widths in light and dark mode', async ({ page }, testInfo) => {
for (const viewport of viewports) {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto('/fundus');
await setTheme(page, viewport.theme);
for (const route of routes) {
await page.goto(route);
await expect(page.locator('html')).toHaveAttribute('data-theme', viewport.theme);
await expectNoHorizontalOverflow(page);
}
}
await page.setViewportSize({ width: 768, height: 900 });
await page.goto('/fundus');
await setTheme(page, 'dark');
await page.goto('/verwaltung');
await page.screenshot({
path: testInfo.outputPath('verwaltung-tablet-dark.png'),
fullPage: true
});
});
test('long titles and longer fundus lists reflow without overflow', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus');
await expect(page.locator('.piece-card')).toHaveCount(1);
await page.evaluate(() => {
const list = document.querySelector('.piece-grid');
const rows = Array.from(document.querySelectorAll('.piece-card'));
const title = rows[0]?.querySelector('h2');
if (title) {
title.textContent =
'Ein außergewöhnlich langer redaktioneller Titel über kleine Konsolen, große Ideen und unerwartete Wendungen';
}
for (let repetition = 0; repetition < 3; repetition += 1) {
for (const row of rows) list?.append(row.cloneNode(true));
}
});
await expect(page.locator('.piece-card')).toHaveCount(4);
await expectNoHorizontalOverflow(page);
await page.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(page);
await expect(page.locator('.piece-card').first()).toHaveCSS('flex-direction', 'column');
const mobileCard = await page.locator('.piece-card').first().boundingBox();
const mobilePrimary = await page.locator('.piece-primary').first().boundingBox();
expect(mobilePrimary?.width ?? 0).toBeGreaterThan((mobileCard?.width ?? 0) - 40);
await expect(page.locator('.piece-card').first().getByRole('heading')).toContainText(
'Ein außergewöhnlich langer redaktioneller Titel'
);
await page.screenshot({
path: testInfo.outputPath('fundus-mobile-long-content.png'),
fullPage: true
});
});
test('inbox, archive and administration stay concise', async ({ page }) => {
for (const width of [390, 1280]) {
await page.setViewportSize({ width, height: 900 });
await page.goto('/eingang');
await expect(page.locator('.capture-panel')).toBeVisible();
await expect(page.locator('.inbox-item')).toHaveCount(1);
await expectNoHorizontalOverflow(page);
await page.goto('/archiv');
await expect(page.locator('.archive-list article')).toHaveCount(1);
await expectNoHorizontalOverflow(page);
}
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/verwaltung');
const panels = page.locator('.taxonomy-panel');
await expect(panels).toHaveCount(3);
const boxes = await Promise.all([
panels.nth(0).boundingBox(),
panels.nth(1).boundingBox(),
panels.nth(2).boundingBox()
]);
expect(boxes[0]?.y).toBe(boxes[1]?.y);
expect(boxes[1]?.y).toBe(boxes[2]?.y);
const topicIconPicker = page.locator('.topic-management-panel .taxonomy-form .icon-picker');
for (const width of [1280, 960, 390]) {
await page.setViewportSize({ width, height: 900 });
await topicIconPicker.getByText('Icon auswählen', { exact: true }).click();
const popover = topicIconPicker.locator('.icon-picker-popover');
await expect(popover).toBeVisible();
const popoverBox = await popover.boundingBox();
expect(popoverBox?.x ?? -1).toBeGreaterThanOrEqual(0);
expect((popoverBox?.x ?? width) + (popoverBox?.width ?? 1)).toBeLessThanOrEqual(width);
await topicIconPicker.getByText('Icon auswählen', { exact: true }).click();
}
await page.setViewportSize({ width: 1280, height: 900 });
await topicIconPicker.getByText('Icon auswählen', { exact: true }).click();
await topicIconPicker.getByPlaceholder('Icon suchen').fill('Satellite Dish');
await topicIconPicker.getByRole('button', { name: 'Satellite Dish', exact: true }).click();
await expect(topicIconPicker.locator('input[type="hidden"]')).toHaveValue('satellite-dish');
});
test('tools stay editable in pieces after catalog deactivation', async ({ page }, testInfo) => {
await page.goto('/verwaltung');
for (const width of [390, 1280]) {
await page.setViewportSize({ width, height: 900 });
await expect(page.locator('.tool-editor-dialog')).toBeHidden();
await expectNoHorizontalOverflow(page);
}
await page.getByRole('button', { name: 'Werkzeug hinzufügen' }).click();
const create = page.getByRole('dialog', { name: 'Neues Werkzeug' });
await create.getByLabel('Name').fill('E2E Tierlist');
await create.getByLabel('Start-URL').fill('https://example.com/tierlist');
await create.getByLabel('Beschreibung').fill('Ranking für die Sendung');
await create.getByText('Icon auswählen', { exact: true }).click();
await create.getByPlaceholder('Icon suchen').fill('Satellite Dish');
await page.screenshot({ path: testInfo.outputPath('tool-icon-picker-desktop.png') });
await create.getByRole('button', { name: 'Satellite Dish', exact: true }).click();
await create.getByText('Formate auswählen', { exact: true }).click();
await create.getByRole('checkbox', { name: 'Quiz' }).check();
await create.getByRole('button', { name: 'Auswahl schließen' }).click();
await page.screenshot({ path: testInfo.outputPath('tool-create-desktop.png'), fullPage: true });
await create.getByRole('button', { name: 'Werkzeug anlegen' }).click();
const tool = page.locator('.tool-settings-list > button').filter({ hasText: 'E2E Tierlist' });
await expect(tool).toContainText('Aktiv');
await expect(tool.locator('.catalog-entry-icon svg')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('tool-catalog-desktop.png'), fullPage: true });
await page.goto('/fundus/piece-quiz');
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await page.getByText('Werkzeuge auswählen', { exact: true }).click();
const toolChoice = page.getByRole('checkbox', { name: /E2E Tierlist/ });
await expect(toolChoice.locator('xpath=..').locator('svg')).toBeVisible();
await toolChoice.check();
await page.getByLabel('Arbeitslink').fill('https://example.com/tierlist/e2e-board');
await page.getByLabel('Notiz').fill('Vor der Sendung sortieren');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
const toolsSection = page.locator('.piece-source-editor').filter({ hasText: 'Werkzeuge' });
await toolsSection.scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath('piece-tools-desktop.png') });
await page.goto('/verwaltung');
const storedTool = page
.locator('.tool-settings-list > button')
.filter({ hasText: 'E2E Tierlist' });
await storedTool.click();
const edit = page.getByRole('dialog', { name: 'E2E Tierlist' });
await edit.getByRole('checkbox', { name: /^Aktiv/ }).uncheck();
await page.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(page);
await expect(edit.getByRole('button', { name: 'Änderungen speichern' })).toBeVisible();
await edit.getByText('Icon auswählen', { exact: true }).click();
await edit.getByPlaceholder('Icon suchen').fill('Satellite Dish');
await page.screenshot({ path: testInfo.outputPath('tool-icon-picker-mobile.png') });
await edit.getByRole('button', { name: 'Satellite Dish', exact: true }).click();
await page.screenshot({ path: testInfo.outputPath('tool-edit-mobile.png') });
await edit.getByRole('button', { name: 'Änderungen speichern' }).click();
await expect(storedTool).toContainText('Deaktiviert');
await page.screenshot({ path: testInfo.outputPath('tool-catalog-mobile.png'), fullPage: true });
await page.goto('/fundus/piece-quiz');
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.locator('.tool-assignment-row')).toContainText('E2E Tierlist');
await expect(page.locator('.tool-assignment-row')).toContainText('Deaktiviert');
await expect(page.getByLabel('Arbeitslink')).toHaveValue(
'https://example.com/tierlist/e2e-board'
);
await page.setViewportSize({ width: 390, height: 844 });
await page
.locator('.piece-source-editor')
.filter({ hasText: 'Werkzeuge' })
.scrollIntoViewIfNeeded();
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('piece-tools-mobile.png') });
});
test('validated YouTube and TikTok players switch without exposing private fields', async ({
page
}, testInfo) => {
addPresentationFixture();
try {
await page.context().route('https://www.youtube-nocookie.com/**', (route) =>
route.fulfill({
contentType: 'text/html',
body: '<!doctype html><title>YouTube Test</title>'
})
);
await page.context().route('https://www.tiktok.com/player/**', (route) =>
route.fulfill({
contentType: 'text/html',
body: '<!doctype html><title>TikTok Test</title>'
})
);
await page.goto('/live?show=e2e-presentation');
await page.getByText('Menü', { exact: true }).click();
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click();
const output = await popupPromise;
await expect(output.locator('.output-content')).toHaveCount(0);
await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/);
await page.getByRole('button', { name: 'YouTube Kurzlink präsentieren' }).click();
const youtube = output.locator('iframe[title="YouTube Vorschau"]');
await expect(youtube).toHaveAttribute(
'src',
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?start=75'
);
await page.getByRole('button', { name: 'Präsentation leeren' }).click();
await expect(youtube).toHaveCount(0);
await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/);
await expect(output.locator('.output-connection')).toHaveClass(/sr-only/);
const publicState = await output.evaluate(async () =>
JSON.stringify(await fetch(`${location.pathname}/state`).then((response) => response.json()))
);
expect(publicState).not.toContain('PRIVATE_RESEARCH');
expect(publicState).not.toContain('PRIVATE_MODERATION');
await page.getByRole('button', { name: 'TikTok Hochformat präsentieren' }).click();
await expect(youtube).toHaveCount(0);
await expect(output.locator('iframe[title="TikTok Vorschau"]')).toHaveAttribute(
'src',
'https://www.tiktok.com/player/v1/6718335390845095173?autoplay=0&controls=1'
);
await expect(output.getByRole('link', { name: 'Original öffnen' })).toHaveAttribute(
'href',
'https://www.tiktok.com/@scout2015/video/6718335390845095173'
);
await output.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(output);
await output.screenshot({
path: testInfo.outputPath('heinzlive-video-mobile.png'),
fullPage: true
});
await output.close();
} finally {
removePresentationFixture();
}
});
test('live control persists state and exposes only explicit presentation data', async ({
page,
browser
}, testInfo) => {
setSectionDuration('section-stories', 300);
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus?q=kleine+Konsole&includePlanned=1');
await page.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }).click();
await page.getByRole('button', { name: 'Präsentation', exact: true }).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('/sendungen/show-october');
const plannedConsole = page
.locator('.editable-usage')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
.first();
await plannedConsole.getByRole('button', { name: /bearbeiten und verschieben/ }).click();
await plannedConsole.getByRole('button', { name: 'Aus Fundus aktualisieren' }).click();
await page.goto('/live?show=show-october');
await expect(page.getByRole('heading', { name: 'Bereit für die Sendung' })).toBeVisible();
await expect(page.locator('.sidebar')).toHaveCount(0);
await expect(page.locator('.mobile-header')).toHaveCount(0);
await expect(page.locator('.mobile-navigation')).toHaveCount(0);
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.getByRole('button', { name: 'Zurück', exact: true })).toBeDisabled();
await expect(page.locator('.section-countdown')).toContainText(/0[45]:\d{2}/);
const liveMenuTrigger = page.getByRole('button', { name: 'Menü', exact: true });
await liveMenuTrigger.click();
const liveMenu = page.getByRole('dialog', { name: 'Regiemenü' });
await expect(liveMenu).toBeVisible();
await expect(liveMenu).toContainText('Herbstsendung: Kleine Welten');
await expect(liveMenu.getByRole('button', { name: 'Live-Stand wiederaufnehmen' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Schrift verkleinern' })).toBeFocused();
await page.keyboard.press('Escape');
await expect(liveMenu).toBeHidden();
await expect(liveMenuTrigger).toBeFocused();
await liveMenuTrigger.press('Enter');
await expect(liveMenu).toBeVisible();
await page.locator('.live-current').click({ position: { x: 8, y: 8 } });
await expect(liveMenu).toBeHidden();
await expect(liveMenuTrigger).toBeFocused();
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
await expect(page.locator('.live-output-state')).toContainText('Leer');
await page.getByText('Quellen · 1', { exact: true }).click();
await expect(page.locator('.live-materials')).toContainText('Herstellerseite');
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.getByText('Menü', { exact: true }).click();
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.locator('.research-details > summary').click();
await expect(page.locator('.research-details')).toHaveAttribute('open', '');
await page.getByText('Menü', { exact: true }).click();
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click()
]);
await popup.waitForURL(/\/heinzlive\/default#/);
const outputUrl = popup.url();
const anonymous = await browser.newContext();
const output = await anonymous.newPage();
await output.goto(outputUrl);
await expect(output.locator('.output-content')).toHaveCount(0);
await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/);
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.getByRole('heading', { name: 'Weniger Leistung, mehr Idee' })).toBeVisible();
await expect(output.locator('.output-topics')).toHaveText('Gaming');
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');
expect(outputHtml).not.toContain('https://example.com/console');
const stateUrl = new URL(outputUrl);
stateUrl.hash = '';
stateUrl.pathname += '/state';
const outputState = await anonymous.request.get(stateUrl.toString());
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: 'Präsentation leeren' }).click();
await expect(output.locator('.output-content')).toHaveCount(0);
await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/);
await page.getByRole('button', { name: 'Präsentieren', exact: true }).click();
await page.getByText('Reserve', { exact: true }).click();
await page.getByRole('button', { name: 'Als Nächstes' }).click();
await expect(page.locator('.live-next-inline')).toContainText('Pixelart');
await page.getByRole('button', { name: 'Erledigen & weiter' }).click();
await expect(page.locator('.live-current')).toContainText('Pixelart');
await page.getByRole('button', { name: 'Überspringen' }).click();
await expect(page.locator('#current-title')).toHaveText(
'Warum praktische Effekte wieder auffallen'
);
await expect(
page.locator('.live-run-item').filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
).toContainText('Erledigt');
await expect(
page.locator('.live-run-item').filter({ hasText: 'Pixelart, die nicht nostalgisch sein will' })
).toContainText('Übersprungen');
await expect(
page.locator('.live-run-item').filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
).toContainText('Aktuell');
await page.locator('.live-current-scroll').evaluate((element) => {
for (let index = 0; index < 12; index += 1) {
const paragraph = document.createElement('p');
paragraph.textContent = 'Temporärer Langtext für die Lesepositionsprüfung.';
element.append(paragraph);
}
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event('scroll'));
});
expect(
await page.locator('.live-current-scroll').evaluate((element) => element.scrollTop)
).toBeGreaterThan(0);
const countdownBeforeBack = await page.locator('.section-countdown strong').textContent();
await page.getByRole('button', { name: 'Zurück', exact: true }).click();
await expect(page.locator('#current-title')).toHaveText(
'Pixelart, die nicht nostalgisch sein will'
);
await expect(
page
.locator('.live-run-item.current')
.filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
).toBeVisible();
await expect(page.getByRole('button', { name: 'Überspringen' })).toBeDisabled();
await expect(page.getByRole('button', { name: /Erledigen/ })).toBeDisabled();
await expect(
output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible();
const countdownAfterBack = await page.locator('.section-countdown strong').textContent();
expect(countdownAfterBack).not.toBe('05:00');
expect(countdownAfterBack?.slice(0, 3)).toBe(countdownBeforeBack?.slice(0, 3));
await page
.getByRole('button', {
name: 'Die kleine Konsole mit dem großen Dreh in der Regie auswählen'
})
.click();
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
await expect
.poll(() => page.locator('.live-current-scroll').evaluate((element) => element.scrollTop))
.toBe(0);
await expect(
page.locator('.live-run-item').filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
).toContainText('Aktuell');
await expect(
output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible();
await page
.getByRole('button', {
name: 'Warum praktische Effekte wieder auffallen in der Regie auswählen'
})
.click();
await expect(page.locator('#current-title')).toHaveText(
'Warum praktische Effekte wieder auffallen'
);
await expect(output.getByText('Wenn das Licht wirklich auf ein Modell fällt.')).toHaveCount(0);
await page.setViewportSize({ width: 1440, height: 900 });
await setTheme(page, 'dark');
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('live-controller-desktop-dark.png') });
await page.setViewportSize({ width: 900, height: 450 });
await setTheme(page, 'light');
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await page.evaluate(() => {
const title = document.querySelector('#current-title');
if (title)
title.textContent =
'Warum praktische Effekte in einer außergewöhnlich langen Live-Moderation wieder auffallen';
const copy = document.querySelector('.prompter-copy');
if (copy) {
for (let index = 0; index < 8; index += 1) {
const paragraph = document.createElement('p');
paragraph.textContent =
'Zusätzlicher langer Moderationstext prüft die unabhängige Scrollfläche ohne die feste Bedienleiste zu verdrängen.';
copy.append(paragraph);
}
}
const rail = document.querySelector('.live-run-scroll');
const section = rail?.querySelector('.rail-section');
if (rail && section) {
for (let index = 0; index < 8; index += 1) rail.append(section.cloneNode(true));
}
});
const compactLayout = await page.evaluate(() => {
const current = document.querySelector('.live-current')?.getBoundingClientRect();
const rail = document.querySelector('.live-rail')?.getBoundingClientRect();
const footer = document.querySelector('.live-action-bar')?.getBoundingClientRect();
const pageStyle = getComputedStyle(document.querySelector('.live-page') as HTMLElement);
const pieceScroll = document.querySelector('.live-current-scroll');
const railScroll = document.querySelector('.live-run-scroll');
return {
pageFits: document.documentElement.scrollHeight <= innerHeight,
twoColumns: Boolean(current && rail && rail.x > current.x + current.width - 1),
footerVisible: Boolean(footer && footer.bottom <= innerHeight && footer.top >= 0),
shortViewportDensity: parseFloat(pageStyle.gap) < 8,
unscaled: pageStyle.transform === 'none',
unzoomed: ['', '1', 'normal'].includes(pageStyle.getPropertyValue('zoom')),
pieceScrolls: Boolean(pieceScroll && pieceScroll.scrollHeight > pieceScroll.clientHeight),
railScrolls: Boolean(railScroll && railScroll.scrollHeight > railScroll.clientHeight)
};
});
expect(compactLayout).toEqual({
pageFits: true,
twoColumns: true,
footerVisible: true,
shortViewportDensity: true,
unscaled: true,
unzoomed: true,
pieceScrolls: true,
railScrolls: true
});
await page.screenshot({ path: testInfo.outputPath('live-controller-compact-desktop.png') });
await page.setViewportSize({ width: 670, height: 450 });
await page.evaluate(() => {
const section = document.querySelector('.live-context-title.section span');
const next = document.querySelector('.live-context-title.next span');
if (section)
section.textContent =
'Aktuelles Segment mit einem außergewöhnlich langen Namen für die kompakte Regie';
if (next)
next.textContent =
'Danach folgt ein Piece mit einem ebenfalls außergewöhnlich langen und vollständigen Titel';
});
const smallHeader = page.locator('.live-command-header');
const smallHeaderBox = await smallHeader.boundingBox();
expect(smallHeaderBox?.height ?? 999).toBeLessThan(105);
await expect(page.locator('.live-context-title.section span')).toHaveCSS('white-space', 'nowrap');
await expect(page.locator('.live-context-title.next span')).toHaveCSS('white-space', 'nowrap');
await page.locator('.live-context-title.section').click();
await expect(page.locator('.live-context-title.section')).toHaveClass(/expanded/);
await expect(page.locator('.live-context-title.section span')).toHaveCSS('white-space', 'normal');
await page.keyboard.press('Escape');
await expect(page.locator('.live-context-title.section')).not.toHaveClass(/expanded/);
await page.locator('.live-context-title.next').focus();
await page.locator('.live-context-title.next').press('Enter');
await expect(page.locator('.live-context-title.next')).toHaveClass(/expanded/);
await page.keyboard.press('Escape');
await liveMenuTrigger.click();
await expect(liveMenu).toBeVisible();
const compactMenuBox = await liveMenu.boundingBox();
expect(compactMenuBox?.x ?? -1).toBeGreaterThanOrEqual(0);
expect(compactMenuBox?.y ?? -1).toBeGreaterThanOrEqual(0);
expect(
Math.ceil((compactMenuBox?.x ?? 999) + (compactMenuBox?.width ?? 999))
).toBeLessThanOrEqual(670);
expect(
Math.ceil((compactMenuBox?.y ?? 999) + (compactMenuBox?.height ?? 999))
).toBeLessThanOrEqual(450);
await liveMenu.getByRole('link', { name: /Zur Planung/ }).scrollIntoViewIfNeeded();
await expect(liveMenu.getByRole('link', { name: /Zur Planung/ })).toBeVisible();
await liveMenu.getByRole('button', { name: 'Zeiten ausblenden' }).click();
await expect(page.locator('.live-piece-time')).toHaveCount(0);
await liveMenu.getByRole('button', { name: 'Zeiten einblenden' }).click();
await expect(page.locator('.live-piece-time')).toBeVisible();
await page.keyboard.press('Escape');
await expect(liveMenuTrigger).toBeFocused();
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('live-controller-670x450.png') });
const countdownBeforeReload = await page.locator('.section-countdown strong').textContent();
await page.reload();
await expect(page.locator('#current-title')).toHaveText(
'Warum praktische Effekte wieder auffallen'
);
const countdownAfterReload = await page.locator('.section-countdown strong').textContent();
expect(countdownAfterReload).not.toBe('05:00');
expect(countdownAfterReload).toMatch(/0[34]:\d{2}/);
expect(countdownBeforeReload).toMatch(/0[34]:\d{2}/);
await page.setViewportSize({ width: 390, height: 700 });
const liveRail = page.locator('.live-rail');
const railClose = liveRail.getByRole('button', { name: 'Ablauf schließen' });
await expect(liveRail).toBeHidden();
await expect(page.locator('.live-action-bar')).toBeVisible();
await expectNoHorizontalOverflow(page);
expect(
await page.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight)
).toBe(true);
await page.screenshot({ path: testInfo.outputPath('live-controller-mobile.png') });
await liveMenuTrigger.click();
const mobileMenuBox = await liveMenu.boundingBox();
expect(Math.ceil((mobileMenuBox?.x ?? 999) + (mobileMenuBox?.width ?? 999))).toBeLessThanOrEqual(
390
);
expect(Math.ceil((mobileMenuBox?.y ?? 999) + (mobileMenuBox?.height ?? 999))).toBeLessThanOrEqual(
700
);
await page.keyboard.press('Escape');
await expect(liveMenuTrigger).toBeFocused();
await page.getByRole('button', { name: /Ablauf ·/ }).click();
await expect(liveRail).toHaveClass(/open/);
await expect(railClose).toBeFocused();
await expect
.poll(async () => {
const box = await liveRail.boundingBox();
return box ? Math.ceil(box.x + box.width) : Number.POSITIVE_INFINITY;
})
.toBeLessThanOrEqual(390);
const mobileRailBox = await liveRail.boundingBox();
expect(mobileRailBox?.width ?? 0).toBeGreaterThan(340);
await expect(page.locator('.live-run-item.current')).toBeInViewport();
await page.screenshot({ path: testInfo.outputPath('live-controller-mobile-run.png') });
await page.keyboard.press('Escape');
await expect(liveRail).toBeHidden();
await expect(page.getByRole('button', { name: /Ablauf ·/ })).toBeFocused();
await page.getByRole('button', { name: /Ablauf ·/ }).click();
await page.setViewportSize({ width: 900, height: 450 });
await expect(liveRail).not.toHaveAttribute('role', 'dialog');
await expect(page.locator('.live-command-header')).not.toHaveAttribute('inert', '');
await page.setViewportSize({ width: 390, height: 700 });
await expect(liveRail).toBeHidden();
await page.getByRole('button', { name: /Ablauf ·/ }).click();
await liveRail
.getByRole('button', {
name: 'Die kleine Konsole mit dem großen Dreh in der Regie auswählen'
})
.click();
await expect(liveRail).toBeHidden();
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
await page.getByRole('button', { name: /Ablauf ·/ }).click();
await liveRail
.getByRole('button', {
name: 'Warum praktische Effekte wieder auffallen in der Regie auswählen'
})
.click();
await expect(liveRail).toBeHidden();
await expect(page.locator('#current-title')).toHaveText(
'Warum praktische Effekte wieder auffallen'
);
await page.getByRole('button', { name: 'Erledigen & Sendungsende' }).click();
await expect(page.getByRole('heading', { name: 'Alle Pieces bearbeitet' })).toBeVisible();
await expect(page.locator('.live-finished-label')).toHaveText('Ablauf abgeschlossen');
await expect(page.getByRole('button', { name: 'Erledigen & weiter' })).toHaveCount(0);
await expectNoHorizontalOverflow(page);
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.getByRole('button', { name: 'Präsentation leeren' }).click();
await expect(output.locator('.output-content')).toHaveCount(0);
await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/);
await page.setViewportSize({ width: 1280, height: 900 });
await page.evaluate(() => {
window.open = () => null;
});
await page.getByText('Menü', { exact: true }).click();
await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click();
await expect(
page
.getByRole('status')
.filter({ hasText: 'Präsentationsfenster konnte nicht geöffnet werden' })
).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.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();
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('heading', { name: 'E2E Fundstück mit Markdown' }).click();
await expect(page).toHaveURL(pieceUrl);
await page.getByText('Status, Dauer und Einordnung').click();
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 page
.getByRole('textbox', { name: 'Private Recherche', exact: true })
.fill('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.getByRole('textbox', { name: 'Private Recherche', exact: true })).toHaveValue(
'Nur intern: **Hintergrund prüfen**.'
);
await openEditorialField(page, 'Präsentationstext');
await page
.getByRole('textbox', { name: 'Öffentlicher Text', exact: true })
.fill('## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **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')).toContainText('Gespeichert', { timeout: 10_000 });
await page.waitForTimeout(1_200);
await expect(page.locator('.save-indicator')).toContainText('Gespeichert');
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 page.reload();
await expect(
page.getByRole('button', { name: 'E2E Fundstück mit Markdown Titel bearbeiten' })
).toBeVisible();
await page.getByText('Status, Dauer und Einordnung').click();
await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25');
await openEditorialField(page, 'Private Recherche');
await expect(page.getByRole('textbox', { name: 'Private Recherche', exact: true })).toHaveValue(
'Nur intern: **Hintergrund prüfen**.'
);
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
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 concurrent.getByText('Status, Dauer und Einordnung').click();
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 concurrent.getByText('Status, Dauer und Einordnung').click();
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('heading', { name: 'E2E Fundstück, extern aktualisiert' }).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 .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('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: '<!doctype html><title>YouTube Test</title>'
})
);
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-item').filter({ hasText: 'Schnelles Video-Fundstück' }).click();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.locator('.source-row').getByLabel('URL')).toHaveValue(
'https://youtu.be/dQw4w9WgXcQ'
);
await expect(page.getByLabel('Status')).toBeHidden();
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('button', { name: 'Präsentation', exact: true }).click();
await page.getByRole('radio', { name: 'Video', exact: true }).check();
await page.getByRole('combobox', { name: 'Quelle', 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('button', { name: 'Präsentation', exact: true }).click();
await expect(page.getByRole('radio', { name: 'Video', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
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('button', { name: 'Präsentation', exact: true }).click();
await page.getByRole('radio', { name: 'Linkvorschau', exact: true }).check();
await page.getByRole('combobox', { name: 'Quelle', 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 expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.reload();
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
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(1);
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 page.getByRole('button', { name: 'Recherche & Quellen' }).click();
await page.locator('.source-row').first().getByLabel('URL').fill('http://127.0.0.1/private');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
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');
});