Files
sendewerk/tests/ui.e2e.ts
T

2378 lines
106 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Locator, type Page } from '@playwright/test';
import Database from 'better-sqlite3';
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';
const adminEmail = 'barkeeper@example.test';
const adminPassword = 'Nur-fuer-E2E-Admin-1234';
const crewEmail = 'crew@example.test';
const crewPassword = 'Nur-fuer-E2E-Crew-1234';
const routes = ['/fundus', '/sendungen', '/eingang', '/archiv', '/verwaltung', '/live'];
const viewports = [
{ width: 390, height: 844, theme: 'light' },
{ width: 390, height: 844, theme: 'dark' },
{ width: 768, height: 900, theme: 'light' },
{ width: 768, height: 900, theme: 'dark' },
{ width: 1440, height: 1000, theme: 'dark' },
{ width: 1440, height: 1000, theme: 'light' }
] as const;
async function expectNoHorizontalOverflow(page: Page) {
const dimensions = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth
}));
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
}
async function setTheme(page: Page, theme: 'light' | 'dark') {
await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme);
}
async function openEditorialField(page: Page, heading: string) {
const pieceNavigation = page.getByRole('navigation', { name: 'Arbeitsbereich' });
if (await pieceNavigation.count()) {
if (heading.includes('Recherche')) {
await pieceNavigation.getByRole('button', { name: 'Recherche & Quellen' }).click();
} else if (heading.includes('Präsentation')) {
const presentation = page.locator('.piece-presentation-panel');
if (!(await presentation.isVisible())) {
await pieceNavigation.getByRole('button', { name: 'Details & Ausgabe' }).click();
}
} else {
await pieceNavigation.getByRole('button', { name: 'Streamtext', exact: true }).click();
}
return;
}
if (!(await page.getByRole('button', { name: 'Streamtext', exact: true }).count())) return;
if (heading.includes('Recherche')) {
await page.getByRole('button', { name: 'Recherche & Quellen' }).click();
} else if (heading.includes('Präsentation')) {
await page.getByRole('button', { name: 'Präsentation', exact: true }).click();
} else {
await page.getByRole('button', { name: 'Streamtext', exact: true }).click();
}
}
async function editInlineTitle(scope: Page | Locator, title: string, value: string) {
await scope.getByRole('button', { name: `${title} Titel bearbeiten` }).click();
await scope.getByLabel('Titel', { exact: true }).fill(value);
await scope.getByLabel('Titel', { exact: true }).press('Enter');
}
async function fillMarkdownSource(page: Page, label: string, value: string) {
const fieldNames: Record<string, string> = {
'Private Recherche': 'researchText',
'Öffentlicher Vorspann': 'presentationText'
};
const field = page.locator(`.rich-markdown-field:has(input[name="${fieldNames[label]}"])`);
if (!(await field.locator('textarea:visible').count())) {
await field.getByRole('textbox', { name: label, exact: true }).click();
await field.getByRole('button', { name: 'Markdown' }).click();
}
await field.locator('textarea').fill(value);
}
async function openPieceMoreDetails(page: Page) {
const details = page.locator('.piece-more-details');
if ((await details.count()) && !(await details.getAttribute('open'))) {
await details.locator('summary').click();
}
}
async function login(page: Page, email = adminEmail, password = adminPassword) {
await page.goto('/login');
await page.getByLabel('E-Mail').fill(email);
await page.getByLabel('Passwort', { exact: true }).fill(password);
await page.getByRole('button', { name: 'Anmelden' }).click();
await expect(page).toHaveURL(/\/fundus$/);
}
async function openSeededShow(page: Page) {
await page.goto('/sendungen');
await page
.locator('.show-card-list')
.getByRole('link', { name: /Herbstsendung: Kleine Welten/ })
.click();
await expect(page).toHaveURL(/\/sendungen\/show-october$/);
}
function latestMailLink(subject: string): string {
const outbox = process.env.SENDEWERK_E2E_OUTBOX_DIR;
if (!outbox) throw new Error('E2E-Outbox ist nicht konfiguriert.');
const messages = readdirSync(outbox)
.sort()
.map(
(file) =>
JSON.parse(readFileSync(path.join(outbox, file), 'utf8')) as {
subject: string;
text: string;
}
)
.filter((message) => message.subject === subject);
const match = messages.at(-1)?.text.match(/https?:\/\/[^\s]+/);
if (!match) throw new Error(`Keine Testmail mit Betreff ${subject} gefunden.`);
return match[0];
}
function addSentHistoryFixture(pieceId: string) {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
const insertShow = database.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status,
revision, created_at_utc, updated_at_utc, completed_at_utc)
VALUES (?, ?, ?, '', '', 'completed', 1, ?, ?, ?)`
);
const insertSection = database.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, 'Ablauf', NULL, 0, NULL, 0)`
);
const insertUsage = database.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot, moderation_snapshot,
transition_snapshot, presentation_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, 0, 'done', 300, 'Historische Verwendung', '', '', '', '', '', '', 0)`
);
database.transaction(() => {
for (const [suffix, title, date] of [
['older', 'Rückblick im September', '2026-09-10T18:00:00.000Z'],
['latest', 'Rückblick im Oktober', '2026-10-10T18:00:00.000Z']
] as const) {
const showId = `e2e-history-${suffix}`;
const sectionId = `e2e-history-section-${suffix}`;
insertShow.run(showId, title, date, date, date, date);
insertSection.run(sectionId, showId);
const usageId = `e2e-history-usage-${suffix}`;
insertUsage.run(usageId, sectionId, pieceId);
database
.prepare(
`INSERT INTO show_finalizations
(show_id, command_id, source_revision, finalized_at_utc) VALUES (?, ?, 1, ?)`
)
.run(showId, `fixture-${suffix}`, date);
database
.prepare(
`INSERT INTO piece_usage_history
(usage_id, show_id, original_piece_id, show_title_snapshot,
usage_title_snapshot, used_at_utc, actual_duration_seconds)
VALUES (?, ?, ?, ?, 'Historische Verwendung', ?, 300)`
)
.run(usageId, showId, pieceId, title, date);
}
})();
database.close();
}
function removeSentHistoryFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) return;
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
database.prepare("DELETE FROM piece_usage_history WHERE show_id LIKE 'e2e-history-%'").run();
database.prepare("DELETE FROM shows WHERE id LIKE 'e2e-history-%'").run();
database.close();
}
function addCompletionFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
const now = '2026-10-25T19:00:00.000Z';
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
mkdirSync(path.join(dataDirectory, 'uploads'), { recursive: true });
writeFileSync(path.join(dataDirectory, 'uploads', 'e2e-share.png'), png);
database.transaction(() => {
database
.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status,
revision, created_at_utc, updated_at_utc)
VALUES ('e2e-completion', 'E2E Abschluss', ?, '', '', 'live', 1, ?, ?)`
)
.run(now, now, now);
database
.prepare(
`INSERT INTO show_sections (id, show_id, name, category_id, position,
explicit_duration_seconds, is_reserve_bank) VALUES
('e2e-completion-section', 'e2e-completion', 'Finaler Ablauf', NULL, 0, NULL, 0),
('e2e-completion-reserve', 'e2e-completion', 'Reservebank', NULL, 1, NULL, 1)`
)
.run();
const insertUsage = database.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
duration_seconds, title_snapshot, intro_snapshot, research_snapshot, moderation_snapshot,
interaction_snapshot, transition_snapshot, presentation_snapshot, own_notes, is_reserve)
VALUES (?, 'e2e-completion-section', ?, ?, ?, 300, ?, ?, ?, ?, ?, ?, '', ?, 0)`
);
insertUsage.run(
'e2e-completion-done',
'piece-console',
0,
'done',
'Gesendeter Abschlussbeitrag',
'FREIGABE_EINLEITUNG',
'PRIVATE_FREIGABE_RECHERCHE',
'FREIGABE_MODERATION',
'FREIGABE_INTERAKTION',
'FREIGABE_UEBERLEITUNG',
'PRIVATE_FREIGABE_NOTIZ'
);
insertUsage.run(
'e2e-completion-skipped',
'piece-effects',
1,
'skipped',
'Übersprungener Abschlussbeitrag',
'',
'PRIVATE_SKIPPED_RESEARCH',
'',
'',
'',
''
);
database
.prepare(
`INSERT INTO show_live_states
(show_id, revision, started_at_utc, ended_at_utc, presentation_revision, updated_at_utc)
VALUES ('e2e-completion', 0, ?, ?, 0, ?)`
)
.run('2026-10-25T18:00:00.000Z', now, now);
database
.prepare(
`INSERT INTO piece_media
(id, piece_id, storage_name, original_name, mime_type, byte_size, alt_text, position, created_at_utc)
VALUES ('e2e-share-media', 'piece-console', 'e2e-share.png', 'intern.png',
'image/png', ?, 'Freigegebenes Abschlussbild', 77, ?)`
)
.run(png.byteLength, now);
database
.prepare(
`INSERT INTO usage_media_snapshots
(id, usage_id, original_media_id, alt_text, position, is_presentation)
VALUES ('e2e-share-snapshot', 'e2e-completion-done', 'e2e-share-media',
'Freigegebenes Abschlussbild', 0, 1)`
)
.run();
})();
database.close();
}
function finalizeCompletionFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
const now = '2026-10-25T19:00:00.000Z';
database.transaction(() => {
const actor = database.prepare('SELECT id FROM users WHERE email = ?').get(adminEmail) as {
id: string;
};
database
.prepare(
`INSERT INTO piece_usage_history
(usage_id, show_id, original_piece_id, show_title_snapshot, usage_title_snapshot,
used_at_utc, actual_duration_seconds)
VALUES ('e2e-completion-done', 'e2e-completion', 'piece-console', 'E2E Abschluss',
'Gesendeter Abschlussbeitrag', ?, 300)`
)
.run(now);
database
.prepare(
`INSERT INTO show_finalizations
(show_id, command_id, source_revision, finalized_by_user_id, finalized_at_utc)
VALUES ('e2e-completion', 'e2e-fixture-finalization', 1, ?, ?)`
)
.run(actor.id, now);
database
.prepare(
`UPDATE shows SET status = 'completed', completed_at_utc = ?, revision = 2,
updated_at_utc = ? WHERE id = 'e2e-completion'`
)
.run(now, now);
})();
database.close();
}
function removeCompletionFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) return;
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
database.transaction(() => {
database
.prepare(
`DELETE FROM show_share_sessions WHERE share_id IN (
SELECT id FROM show_shares WHERE show_id = 'e2e-completion'
)`
)
.run();
database.prepare("DELETE FROM show_shares WHERE show_id = 'e2e-completion'").run();
database.prepare("DELETE FROM piece_usage_history WHERE show_id = 'e2e-completion'").run();
database.prepare("DELETE FROM shows WHERE id = 'e2e-completion'").run();
database.prepare("DELETE FROM piece_media WHERE id = 'e2e-share-media'").run();
})();
database.close();
rmSync(path.join(dataDirectory, 'uploads', 'e2e-share.png'), { force: true });
}
function addRssSelectionFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
const now = '2026-10-26T12:00:00.000Z';
const insertFeed = database.prepare(
`INSERT INTO feed_sources
(id, name, url, is_enabled, refresh_interval_minutes, created_at_utc, updated_at_utc)
VALUES (?, ?, ?, 1, 60, ?, ?)`
);
const insertEntry = database.prepare(
`INSERT INTO feed_entries
(id, feed_id, external_id, title, url, summary, published_at_utc, first_seen_at_utc, updated_at_utc)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
database.transaction(() => {
insertFeed.run('e2e-feed-a', 'E2E Quelle A', 'https://example.org/e2e-a.xml', now, now);
insertFeed.run('e2e-feed-b', 'E2E Quelle B', 'https://example.org/e2e-b.xml', now, now);
for (let index = 1; index <= 25; index += 1) {
const published = new Date(Date.UTC(2026, 8, index)).toISOString();
insertEntry.run(
`e2e-entry-a-${index}`,
'e2e-feed-a',
`a-${index}`,
`E2E Quelle A Beitrag ${index}`,
`https://example.org/a/${index}`,
`Auszug für Beitrag ${index} aus Quelle A.`,
published,
now,
now
);
}
for (let index = 1; index <= 20; index += 1) {
const published = new Date(Date.UTC(2026, 7, index)).toISOString();
insertEntry.run(
`e2e-entry-b-${index}`,
'e2e-feed-b',
`b-${index}`,
`E2E Quelle B Beitrag ${index}`,
`https://example.org/b/${index}`,
`Auszug für Beitrag ${index} aus Quelle B.`,
published,
now,
now
);
}
})();
database.close();
}
function removeIntegrationFixtures() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) return;
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.pragma('foreign_keys = ON');
database.transaction(() => {
const pieceIds = database
.prepare(
"SELECT piece_id AS id FROM feed_entries WHERE id LIKE 'e2e-entry-%' AND piece_id IS NOT NULL"
)
.all() as { id: string }[];
database.prepare("DELETE FROM feed_sources WHERE id LIKE 'e2e-feed-%'").run();
for (const piece of pieceIds) database.prepare('DELETE FROM pieces WHERE id = ?').run(piece.id);
const accessIds = database
.prepare("SELECT id FROM technical_accesses WHERE name LIKE 'E2E API-Zugang%'")
.all() as { id: string }[];
for (const access of accessIds) {
database.prepare('DELETE FROM audit_events WHERE target_id = ?').run(access.id);
database.prepare('DELETE FROM technical_accesses WHERE id = ?').run(access.id);
}
})();
database.close();
}
function addCalendarFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
const insert = database.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status,
revision, created_at_utc, updated_at_utc) VALUES (?, ?, ?, '', '', 'draft', 1, ?, ?)`
);
const now = '2026-10-25T10:00:00.000Z';
database.transaction(() => {
insert.run('e2e-calendar-late', 'Kalender Spättermin', '2026-10-25T18:00:00.000Z', now, now);
insert.run('e2e-calendar-early', 'Kalender Frühtermin', '2026-10-25T09:00:00.000Z', now, now);
insert.run(
'e2e-calendar-next',
'Kalender Novembertermin',
'2026-11-03T17:00:00.000Z',
now,
now
);
insert.run('e2e-calendar-undated', 'Kalender ohne Termin', null, now, now);
})();
database.close();
}
function removeCalendarFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) return;
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.prepare("DELETE FROM shows WHERE id LIKE 'e2e-calendar-%'").run();
database.close();
}
function setUsagePreparation(
usageId: string,
status: 'preparing' | 'ready',
effort: 'low' | 'medium' | 'high' | null
) {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database
.prepare(
`UPDATE piece_usages SET preparation_status_snapshot = ?,
preparation_effort_snapshot = ? WHERE id = ?`
)
.run(status, effort, usageId);
database.close();
}
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
await page.goto('/');
if (await page.getByRole('heading', { name: 'Ersten Barkeeper anlegen' }).isVisible()) {
await page.getByLabel('Betreiber-Setup-Token').fill('e2e-only-setup-token-32-characters');
await page.getByLabel('Name').fill('E2E Barkeeper');
await page.getByLabel('E-Mail').fill(adminEmail);
await page.getByLabel('Passwort').fill(adminPassword);
await page.getByRole('button', { name: 'Barkeeper anlegen' }).click();
} else {
await page.getByLabel('E-Mail').fill(adminEmail);
await page.getByLabel('Passwort').fill(adminPassword);
await page.getByRole('button', { name: 'Anmelden' }).click();
}
await expect(page).toHaveURL(/\/fundus$/);
const sessionCookie = (await page.context().cookies()).find(
(cookie) => cookie.name === 'sendewerk_session'
);
expect(sessionCookie?.httpOnly).toBe(true);
expect(sessionCookie?.secure).toBe(false);
expect(sessionCookie?.sameSite).toBe('Lax');
await page.goto('/setup');
await expect(page).toHaveURL(/\/fundus$/);
await page.locator('.app-header .account-menu > summary').click();
await page.getByRole('button', { name: 'Abmelden', exact: true }).click();
await expect(page).toHaveURL(/\/login$/);
await page.close();
});
test.beforeEach(async ({ page }) => {
await login(page);
});
test('dashboard, global search and responsive primary navigation use the shared app chrome', async ({
page
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Was als Nächstes ansteht' })).toBeVisible();
for (const label of ['Start', 'Eingang', 'Pieces', 'Streams', 'Live', 'Post']) {
await expect(
page.locator('.main-navigation').getByRole('link', { name: label, exact: true })
).toBeVisible();
}
await page.getByPlaceholder('Pieces und Streams suchen').fill('Herstellerangaben');
await page.getByPlaceholder('Pieces und Streams suchen').press('Enter');
await expect(page).toHaveURL(/\/suche\?q=Herstellerangaben/);
await expect(page.getByRole('heading', { name: 'Pieces', exact: true })).toBeVisible();
await expect(page.getByText('Die kleine Konsole mit dem großen Dreh')).toBeVisible();
await expect(page.getByText('Recherche', { exact: true })).toBeVisible();
await expect(page.locator('.search-filter-panel')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('suche-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: 'Filter' }).click();
await expect(page.locator('.search-filter-panel')).toBeVisible();
await expect(page.getByLabel('Piece-Thema')).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('suche-mobile-filter.png'), fullPage: true });
await page.goto('/');
await expect(page.locator('.app-header')).toBeHidden();
await expect(
page.locator('.mobile-header').getByPlaceholder('Suchen', { exact: true })
).toBeVisible();
await expect(page.locator('.mobile-navigation').getByRole('link')).toHaveCount(6);
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('start-mobile.png'), fullPage: true });
});
test('post-production manages completed shows, source material, videos, clips and valid exports', async ({
page
}, testInfo) => {
test.setTimeout(60_000);
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/post');
await expect(
page.locator('.post-page-heading').getByRole('heading', { name: 'Postproduktion' })
).toBeVisible();
await expect(page.locator('.post-navigation-panel')).toHaveClass(/fundus-navigation/);
await expect(page.locator('.content-quick-inspector')).toHaveClass(/stream-inspector/);
const completedShow = page.locator('.post-show-row').filter({
hasText: 'Frühere Sendung: Linux, Spiele und Netzkultur'
});
await expect(completedShow).toContainText('Noch nicht begonnen');
await expect(completedShow).toContainText('0');
await page.screenshot({ path: testInfo.outputPath('post-overview-1440.png'), fullPage: true });
for (const [width, height] of [
[960, 900],
[390, 844]
] as const) {
await page.setViewportSize({ width, height });
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath(`post-overview-${width}.png`),
fullPage: true
});
}
await page.setViewportSize({ width: 1440, height: 1000 });
await completedShow.click();
await expect(page.locator('.post-workspace')).toBeVisible();
await expect(page.locator('.post-detail-page')).toBeVisible();
await expect(page.locator('.editor-workspace-dialog')).toHaveCount(0);
await expect(page.locator('.post-editor')).toHaveClass(/builder-pane/);
await expect(page.locator('.post-inspector')).toHaveClass(/stream-inspector/);
await expect(page.locator('.post-navigation')).toContainText('YouTube');
await expect(page.locator('.post-content-overview')).toBeVisible();
await page.screenshot({
path: testInfo.outputPath('post-detail-overview-1440.png'),
fullPage: true
});
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: 'Inhalte', exact: true }).click();
await expect(page.locator('.post-navigation')).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('post-mobile-navigation.png'),
fullPage: true
});
await page.setViewportSize({ width: 1440, height: 1000 });
await page.getByRole('link', { name: 'Postproduktion', exact: true }).first().click();
await expect(page).toHaveURL(/\/post$/);
await completedShow.click();
await expect(page.locator('.post-workspace')).toBeVisible();
await page.goBack();
await expect(page).toHaveURL(/\/post$/);
await completedShow.click();
await expect(page.locator('.post-workspace')).toBeVisible();
await page.getByRole('button', { name: /Material & Quellen/ }).click();
const materials = page.getByRole('dialog', { name: 'Aufzeichnungen & Kontextdateien' });
await materials.getByText('Aufzeichnungsteil hinzufügen').click();
await materials.getByLabel('Bezeichnung').fill('Teil 1');
await materials.getByLabel('Mediendateipfad').fill('/media/heinzlive-teil-1.mov');
await materials.getByLabel('Framerate').fill('25');
await materials.getByLabel('Timebase').fill('25');
await materials.getByRole('button', { name: 'Teil anlegen' }).click();
await page.getByRole('button', { name: /Material & Quellen/ }).click();
await materials.locator('input[name="source"]').setInputFiles({
name: 'heinzlive-kontext-teil-1.md',
mimeType: 'text/markdown',
buffer: Buffer.from('# Kontext\n\n[00:01:12] Linux ist keine Religion!')
});
await materials.getByLabel('Aufzeichnungsteil').selectOption({ label: 'Teil 1' });
await materials.getByRole('button', { name: 'Markdown importieren' }).click();
await page.getByRole('button', { name: 'YouTube anlegen' }).click();
await expect(page.getByRole('heading', { name: 'Uncut-Video' })).toBeVisible();
await page.getByLabel('Arbeitstitel').fill('Linux, Spiele und Netzkultur – Uncut');
await page
.getByPlaceholder('Beschreibung, Kapitel, Links und Hashtags')
.fill('00:00 Begrüßung\n01:12 Linux ist keine Religion!\n\n#heinzlive');
await page
.locator('.post-inspector')
.getByLabel('Aufzeichnungsteil')
.selectOption({ label: 'Teil 1' });
await expect(page.locator('.post-inspector').getByLabel('Kontextdatei')).toHaveValue(/.+/);
await page.locator('.post-inspector select[name="status"]').selectOption('review');
await page.getByRole('button', { name: 'Speichern', exact: true }).first().click();
await expect(page.locator('.post-editor-header')).toContainText('Gespeichert');
const thumbnail = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
await page.locator('.post-thumbnail-upload').getByLabel('Bild').setInputFiles({
name: 'uncut-thumbnail.png',
mimeType: 'image/png',
buffer: thumbnail
});
await page
.locator('.post-thumbnail-upload')
.getByLabel('Alternativtext')
.fill('Fertiges Uncut-Thumbnail');
await page.getByRole('button', { name: 'Thumbnail hochladen' }).click();
await expect(page.locator('.post-thumbnail-grid img')).toBeVisible();
const thumbnailDownload = page.getByRole('link', { name: 'Herunterladen' });
const downloadResponse = await page.request.get(
(await thumbnailDownload.getAttribute('href')) as string
);
expect(await downloadResponse.body()).toEqual(thumbnail);
await page.getByRole('button', { name: 'Social-Clips anlegen' }).click();
await page.getByLabel('Arbeitstitel').fill('Linux ist keine Religion!');
await page
.getByPlaceholder('Nur das tatsächliche Originalmaterial eintragen')
.fill('Linux ist keine Religion!');
await page
.locator('.post-inspector')
.getByLabel('Aufzeichnungsteil')
.selectOption({ label: 'Teil 1' });
await expect(page.locator('.post-inspector').getByLabel('Kontextdatei')).toHaveValue(/.+/);
await page.locator('.post-inspector').getByLabel('Start (Sek.)').fill('72');
await page.locator('.post-inspector').getByLabel('Ende (Sek.)').fill('103');
await page.locator('.post-inspector').getByLabel('Cliptyp').selectOption('opinion');
await page.getByRole('button', { name: 'Plattformtexte' }).click();
await page.getByLabel('Öffentlicher Titel').fill('Linux ist keine Religion!');
await page
.getByLabel('Gemeinsame Caption inklusive Hashtags')
.fill('Linux ist keine Religion!\n\n#linux #heinzlive');
await page.getByRole('button', { name: 'Hooks & Endcard' }).click();
await page.getByLabel('Primärer Hook').fill('Linux ist keine Religion!');
await page.getByLabel('Schluss-Einblendung').fill('Mehr im vollständigen Stream.');
await page.getByRole('button', { name: 'Schnitt' }).click();
await page
.getByLabel('SRT-Untertitel')
.fill('1\n00:00:00,000 --> 00:00:02,000\nLinux ist keine Religion!');
await page.getByRole('button', { name: 'Speichern', exact: true }).first().click();
await expect(page.locator('.post-inspector')).toContainText('31 Sekunden');
await expect(page.getByRole('link', { name: 'DaVinci Resolve XML' })).toBeVisible();
await expect(page.getByRole('link', { name: 'SRT-Untertitel' })).toBeVisible();
const xmlHref = await page
.getByRole('link', { name: 'DaVinci Resolve XML' })
.getAttribute('href');
const xml = await page.request.get(xmlHref as string);
expect(await xml.text()).toContain('<xmeml version="5">');
const srtHref = await page.getByRole('link', { name: 'SRT-Untertitel' }).getAttribute('href');
expect(await (await page.request.get(srtHref as string)).text()).toContain(
'Linux ist keine Religion!'
);
await page.screenshot({ path: testInfo.outputPath('post-desktop-clip.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: 'Details', exact: true }).click();
await expect(page.locator('.post-inspector')).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('post-mobile-details.png'), fullPage: true });
});
test('invitations, direct authorization, locking and password reset work end to end', async ({
page,
browser
}, testInfo) => {
await page.goto('/verwaltung');
await page.screenshot({
path: testInfo.outputPath('verwaltung-konten-desktop.png'),
fullPage: true
});
const crossSite = await page.request.post('/verwaltung?/invite', {
form: { email: 'cross-site@example.test', role: 'barkeeper' },
headers: { origin: 'https://example.invalid' }
});
expect(crossSite.status()).toBe(403);
const inviteForm = page.locator('.invite-panel').filter({ hasText: 'Neues Konto einladen' });
await inviteForm.getByLabel('E-Mail').fill(crewEmail);
await inviteForm.getByRole('button', { name: 'Einladung erstellen' }).click();
await expect(
page.getByText('Einladung wurde in der lokalen Mail-Outbox abgelegt.')
).toBeVisible();
const crewContext = await browser.newContext();
const crewPage = await crewContext.newPage();
await crewPage.goto(latestMailLink('Einladung zu Sendewerk'));
await expect(crewPage).toHaveURL(/\/einladung$/);
await crewPage.getByLabel('Name').fill('E2E Crew');
await crewPage.getByLabel('Passwort').fill(crewPassword);
await crewPage.getByRole('button', { name: 'Konto aktivieren' }).click();
await expect(crewPage).toHaveURL(/\/fundus$/);
await expect(crewPage.getByText('Verwaltung', { exact: true })).toHaveCount(0);
const forbidden = await crewPage.request.post('/verwaltung?/invite', {
form: { email: 'verboten@example.test', role: 'barkeeper' },
headers: { origin: 'http://127.0.0.1:4173' }
});
expect(forbidden.status()).toBe(403);
await page.reload();
const crewRow = page.locator('.account-row').filter({ hasText: crewEmail });
await crewRow.getByRole('button', { name: 'Sperren' }).click();
await crewPage.goto('/fundus');
await expect(crewPage).toHaveURL(/\/login$/);
await page.reload();
await page
.locator('.account-row')
.filter({ hasText: crewEmail })
.getByRole('button', { name: 'Entsperren' })
.click();
await crewPage.goto('/passwort-vergessen');
await crewPage.getByLabel('E-Mail').fill(crewEmail);
await crewPage.getByRole('button', { name: 'Reset-Link anfordern' }).click();
await expect(crewPage.getByText('Anfrage verarbeitet.')).toBeVisible();
await crewPage.goto(latestMailLink('Sendewerk-Passwort zurücksetzen'));
await expect(crewPage).toHaveURL(/\/passwort-zuruecksetzen$/);
await crewPage.getByLabel('Neues Passwort').fill('Neues-E2E-Crew-Passwort-1234');
await crewPage.getByRole('button', { name: 'Passwort speichern' }).click();
await login(crewPage, crewEmail, 'Neues-E2E-Crew-Passwort-1234');
await crewContext.close();
const anonymousContext = await browser.newContext({ viewport: { width: 390, height: 844 } });
const loginPage = await anonymousContext.newPage();
await loginPage.goto('/login');
await setTheme(loginPage, 'dark');
await loginPage.reload();
await expectNoHorizontalOverflow(loginPage);
await loginPage.screenshot({
path: testInfo.outputPath('login-mobile-dark.png'),
fullPage: true
});
await anonymousContext.close();
});
test('desktop fundus is a compact, filterable list with persistent themes', async ({
page
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/fundus');
await expect(page.getByRole('heading', { name: 'Pieces', exact: true }).first()).toBeVisible();
await expect(page.locator('.app-header')).toBeVisible();
await expect(page.locator('.mobile-navigation')).toBeHidden();
await expect(page.locator('.app-header .account-menu > summary')).toBeVisible();
await expect(page.locator('.piece-card')).toHaveCount(1);
const navigationBox = await page.locator('.fundus-navigation').boundingBox();
const listBox = await page.locator('.fundus-library-content').boundingBox();
expect(navigationBox?.x).toBeLessThan(listBox?.x ?? 0);
await expect(page.locator('.content-quick-inspector')).toBeVisible();
await expect(page.locator('.content-quick-inspector')).toHaveClass(/stream-inspector/);
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await page.getByLabel('Auch eingeplante Inhalte zeigen').check();
await expect(page.locator('.piece-card')).toHaveCount(4);
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = false));
await page
.getByRole('button', { name: 'Die kleine Konsole mit dem großen Dreh als Favorit markieren' })
.click();
await expect(
page.getByRole('button', {
name: 'Die kleine Konsole mit dem großen Dreh aus Favoriten entfernen'
})
).toBeVisible();
await expect(page.locator('.piece-card').first()).toContainText(
'Die kleine Konsole mit dem großen Dreh'
);
await page
.locator('.piece-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
.getByRole('link', { name: 'Die kleine Konsole mit dem großen Dreh', exact: true })
.click();
await expect(page).toHaveURL(/\/fundus\?includePlanned=1&selected=piece-console$/);
await page.getByRole('link', { name: 'Vollständigen Piece-Editor öffnen' }).click();
await expect(page).toHaveURL(/\/fundus\/piece-console\?includePlanned=1$/);
await page.keyboard.press('Escape');
await expect(page).toHaveURL(/\/fundus\?includePlanned=1$/);
await page
.locator('.piece-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
.getByRole('link', { name: 'Die kleine Konsole mit dem großen Dreh', exact: true })
.click();
await page.getByRole('link', { name: 'Vollständigen Piece-Editor öffnen' }).click();
await page.getByRole('button', { name: 'Editor schließen' }).click();
await expect(page).toHaveURL(/\/fundus\?includePlanned=1$/);
await expect(page.getByLabel('Auch eingeplante Inhalte zeigen')).toBeChecked();
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
const firstRow = await page.locator('.piece-card').nth(0).boundingBox();
const secondRow = await page.locator('.piece-card').nth(1).boundingBox();
expect(firstRow?.x).toBe(secondRow?.x);
expect(firstRow?.y).toBeLessThan(secondRow?.y ?? 0);
expect(firstRow?.height ?? 999).toBeLessThan(150);
await page.getByRole('searchbox', { name: 'Pieces durchsuchen' }).fill('Pixelart');
await page.getByRole('searchbox', { name: 'Pieces durchsuchen' }).press('Enter');
await expect(page.locator('.piece-card')).toHaveCount(1);
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await page.locator('.filter-picker').getByLabel('Status').selectOption('preparing');
await page.getByRole('button', { name: 'Filter anwenden' }).click();
await expect(page.getByRole('heading', { name: 'Kein passendes Piece' })).toBeVisible();
await page
.locator('.filter-picker')
.evaluate((element) => ((element as HTMLDetailsElement).open = true));
await page.getByRole('button', { name: 'Zurücksetzen' }).click();
await expect(page.locator('.piece-card')).toHaveCount(1);
await page.locator('.app-header .account-menu > summary').click();
await page.getByRole('button', { name: 'Hell' }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light');
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light');
await page.locator('.app-header .account-menu > summary').click();
await expect(page.getByRole('button', { name: 'Hell' })).toHaveAttribute('aria-pressed', 'true');
await page.locator('.app-header .account-menu > summary').click();
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('fundus-desktop-light.png'), fullPage: true });
for (const [width, height] of [
[960, 900],
[390, 844]
] as const) {
await page.setViewportSize({ width, height });
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath(`fundus-overview-${width}.png`),
fullPage: true
});
}
});
test('sent and planned source filters stay independent and removing the last usage offers the piece again', async ({
page
}, testInfo) => {
addSentHistoryFixture('piece-effects');
try {
await page.goto('/fundus');
await expect(
page.getByRole('heading', { name: 'Warum praktische Effekte wieder auffallen' })
).toHaveCount(0);
await page.evaluate(() => (document.body.dataset.filterDocument = 'stable'));
await page.getByRole('link', { name: /Abgeschlossen/ }).click();
const usedPiece = page
.locator('.piece-card')
.filter({ hasText: 'Warum praktische Effekte wieder auffallen' });
await expect(usedPiece).toBeVisible();
await expect(usedPiece).toContainText('Gesendet am 10.10.2026');
await expect(usedPiece).toContainText('+ 1 weitere');
await expect(usedPiece.getByRole('link', { name: 'Rückblick im Oktober' })).toHaveAttribute(
'href',
'/sendungen/e2e-history-latest'
);
await usedPiece.getByText('Vollständige Verwendungshistorie').click();
await expect(usedPiece.locator('.piece-usage-history li')).toHaveCount(2);
await expect(page.locator('body')).toHaveAttribute('data-filter-document', 'stable');
await page.getByRole('searchbox', { name: 'Pieces durchsuchen' }).fill('praktische Effekte');
await page.getByRole('searchbox', { name: 'Pieces durchsuchen' }).press('Enter');
await expect(page).toHaveURL(/view=completed/);
await expect(usedPiece).toBeVisible();
await page.screenshot({
path: testInfo.outputPath('fundus-verwendungshistorie-desktop.png'),
fullPage: true
});
await page.goto('/sendungen');
await page.locator('.show-create-panel > summary').click();
await page.getByLabel('Titel', { exact: true }).fill('E2E Filterprüfung');
await page.getByRole('radio', { name: /Standardablauf/ }).check();
await page.getByRole('button', { name: 'Stream anlegen' }).click();
await page.getByRole('button', { name: 'Pieces auswählen' }).click();
await page.getByPlaceholder('Pieces durchsuchen').fill('kleine Konsole');
await page.getByRole('button', { name: 'Filtern' }).click();
const consolePiece = page
.locator('.builder-source-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' });
await consolePiece.getByRole('button', { name: 'Hinzufügen' }).click();
await expect(consolePiece).toHaveCount(0);
await page.getByPlaceholder('Pieces durchsuchen').fill('');
await page.getByRole('button', { name: 'Filtern' }).click();
await page.getByLabel('Auch eingeplante Inhalte zeigen').check();
await expect(
page
.locator('.builder-source-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
).toContainText('Eingeplant');
await expect(
page
.locator('.builder-source-card')
.filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
).toHaveCount(0);
await page.getByPlaceholder('Pieces durchsuchen').fill('kleine Konsole');
await page.getByRole('button', { name: 'Filtern' }).click();
await expect(page.getByLabel('Auch eingeplante Inhalte zeigen')).toBeChecked();
await expect(
page
.locator('.builder-source-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
).toContainText('Eingeplant');
await page.getByPlaceholder('Pieces durchsuchen').fill('');
await page.getByRole('button', { name: 'Filtern' }).click();
await page.getByLabel('Auch eingeplante Inhalte zeigen').uncheck();
await page.getByLabel('Auch verwendete Inhalte zeigen').check();
await expect(
page
.locator('.builder-source-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
).toHaveCount(0);
await expect(
page
.locator('.builder-source-card')
.filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
).toContainText('Rückblick im Oktober');
await page.screenshot({
path: testInfo.outputPath('sendungsfilter-desktop.png'),
fullPage: true
});
page.once('dialog', (dialog) => dialog.accept());
await page
.locator('.reserve-usage')
.getByRole('button', { name: /aus Stream entfernen/ })
.click();
await page.getByLabel('Auch verwendete Inhalte zeigen').uncheck();
await expect(
page
.locator('.builder-source-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: 'Pieces', exact: true }).click();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('sendungsfilter-mobile.png'),
fullPage: true
});
} finally {
removeSentHistoryFixture();
}
});
test('show overview switches persistently between list, calendar and mobile agenda', async ({
page
}, testInfo) => {
addCalendarFixture();
try {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/sendungen?month=2026-10');
await page.getByRole('button', { name: 'Kalender' }).click();
await expect(page.getByRole('heading', { name: 'Oktober 2026' })).toBeVisible();
const octoberDay = page
.locator('.show-calendar-day')
.filter({ hasText: 'Kalender Frühtermin' });
await expect(octoberDay).toContainText('Kalender Spättermin');
await expect(octoberDay.locator('.show-calendar-event')).toHaveCount(2);
await expect(octoberDay.locator('.show-calendar-event').first()).toContainText('10:00');
await expect(page.locator('.show-undated')).toContainText('Kalender ohne Termin');
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('sendungen-kalender-desktop.png'),
fullPage: true
});
await page.getByRole('link', { name: 'Nächster Monat' }).click();
await expect(page.getByRole('heading', { name: 'November 2026' })).toBeVisible();
await expect(
page.locator('.show-calendar-event').filter({ hasText: 'Kalender Novembertermin' })
).toBeVisible();
await page.reload();
await expect(page.getByRole('button', { name: 'Kalender' })).toHaveAttribute(
'aria-pressed',
'true'
);
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('link', { name: 'Vorheriger Monat' }).click();
await expect(page.locator('.show-calendar')).toBeHidden();
await expect(page.locator('.show-agenda')).toContainText('Kalender Frühtermin');
await expect(page.locator('.show-agenda')).toContainText('Kalender Spättermin');
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('sendungen-kalender-mobile.png'),
fullPage: true
});
await page.getByRole('button', { name: 'Liste' }).click();
await expect(page.getByRole('heading', { name: 'Streams', exact: true }).last()).toBeVisible();
} finally {
removeCalendarFixture();
}
});
test('preparation opens and updates the concrete scheduled usage', async ({ page }) => {
setUsagePreparation('usage-console', 'preparing', 'high');
try {
await page.goto('/sendungen');
const preparation = page.locator('.preparation-overview');
await expect(preparation).toContainText('Die kleine Konsole mit dem großen Dreh');
await expect(preparation).toContainText('Hoch');
await preparation.getByRole('link', { name: /Die kleine Konsole/ }).click();
await expect(page).toHaveURL(/usage=usage-console/);
const editor = page.locator('.stream-inspector');
await expect(editor).toBeVisible();
await expect(editor).toContainText('Streamfassung');
await editor.getByLabel('Vorbereitungsstatus').selectOption('ready');
await editor.getByRole('button', { name: 'Speichern', exact: true }).click();
await page.goto('/sendungen');
await expect(page.locator('.preparation-overview')).toHaveCount(0);
} finally {
setUsagePreparation('usage-console', 'ready', null);
}
});
test('desktop builder opens the piece library beside the wider run sheet on demand', async ({
page
}, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus');
await setTheme(page, 'dark');
await openSeededShow(page);
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(page.getByRole('heading', { name: 'Herbstsendung: Kleine Welten' })).toBeVisible();
await expect(page.locator('.source-pane')).toBeHidden();
await expect(page.locator('.run-pane')).toBeVisible();
const streamInspector = page.locator('.stream-inspector');
await expect(
streamInspector.getByRole('heading', { name: 'Streamdaten und Twitch-Texte' })
).toBeVisible();
await expect(streamInspector.getByLabel('Twitch-Titel')).toBeVisible();
await expect(page.locator('.mobile-pane-switch')).toBeHidden();
await expect(
page.locator('.show-summary strong').filter({ hasText: '1 Std. 25 Min.' })
).toBeVisible();
await page.getByRole('button', { name: 'Pieces auswählen' }).click();
await expect(page.locator('.source-pane')).toBeVisible();
const sourceBox = await page.locator('.source-pane').boundingBox();
const runBox = await page.locator('.run-pane').boundingBox();
const inspectorBox = await streamInspector.boundingBox();
expect(sourceBox?.x).toBeLessThan(runBox?.x ?? 0);
expect(sourceBox?.width ?? 9999).toBeLessThan(runBox?.width ?? 0);
expect(runBox?.x ?? 9999).toBeLessThan(inspectorBox?.x ?? 0);
expect(inspectorBox?.width ?? 0).toBeGreaterThanOrEqual(288);
expect(inspectorBox?.width ?? 9999).toBeLessThanOrEqual(352);
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('sendungen-desktop-dark.png'),
fullPage: true
});
await page.locator('.section-drag-handle').first().click();
await expect(streamInspector).toContainText('Abschnitt');
await expect(streamInspector.getByLabel('Kategorie')).toBeVisible();
await streamInspector.getByRole('button', { name: 'Streamdaten' }).click();
const firstUsage = page.locator('.editable-usage').first();
await firstUsage.getByRole('button', { name: /bearbeiten und verschieben/ }).click();
await expect(streamInspector).toContainText('Streamfassung');
await expect(streamInspector.getByLabel('Dauer in Minuten')).toBeVisible();
await expect(streamInspector.getByRole('link', { name: 'Piece bearbeiten' })).toBeVisible();
const usageFooterBox = await streamInspector.locator('.stream-inspector-actions').boundingBox();
expect(usageFooterBox?.height).toBeLessThan(150);
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('stream-usage-editor-desktop-dark.png'),
fullPage: true
});
await streamInspector.getByRole('button', { name: 'Streamdaten' }).click();
await expect(
streamInspector.getByRole('heading', { name: 'Streamdaten und Twitch-Texte' })
).toBeVisible();
});
test('narrow desktop retains the horizontal navigation and switches the builder panes', async ({
page
}) => {
await page.setViewportSize({ width: 960, height: 850 });
await openSeededShow(page);
await expect(page.locator('.app-header')).toBeVisible();
await expect(page.locator('.mobile-navigation')).toBeHidden();
await expect(page.locator('.mobile-pane-switch')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible();
await expect(page.locator('.source-pane')).toBeHidden();
await page.getByRole('button', { name: 'Pieces', exact: true }).click();
await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeHidden();
await expectNoHorizontalOverflow(page);
});
test('mobile and tablet navigation do not obscure switchable content', async ({
page
}, testInfo) => {
for (const width of [390, 768]) {
await page.setViewportSize({ width, height: 900 });
await openSeededShow(page);
await expect(page.locator('.sidebar')).toBeHidden();
await expect(page.locator('.mobile-navigation')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible();
await page.getByRole('button', { name: 'Pieces', exact: true }).click();
await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeHidden();
await expectNoHorizontalOverflow(page);
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
const verticalSpace = await page.evaluate(() => {
const navigation = document.querySelector('.mobile-navigation')?.getBoundingClientRect();
const content = document.querySelector('.source-pane')?.getBoundingClientRect();
return { contentBottom: content?.bottom ?? 0, navigationTop: navigation?.top ?? 0 };
});
expect(verticalSpace.contentBottom).toBeLessThanOrEqual(verticalSpace.navigationTop + 1);
}
await page.setViewportSize({ width: 390, height: 844 });
await openSeededShow(page);
await page.screenshot({ path: testInfo.outputPath('sendungen-mobile.png'), fullPage: true });
});
test('a show can be planned from a template with independent usages and reserve', async ({
page
}, testInfo) => {
test.slow();
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/sendungen');
await page.locator('.show-create-panel > summary').click();
await page.getByLabel('Titel', { exact: true }).fill('E2E Planung Spiele und JC');
await page.getByRole('radio', { name: /Standardablauf/ }).check();
await page.getByRole('button', { name: 'Stream anlegen' }).click();
await expect(page).toHaveURL(/\/sendungen\/[a-f0-9-]+$/);
const showUrl = page.url();
await expect(page.locator('.run-section')).toHaveCount(5);
const firstSection = page.locator('.run-section').first();
const sectionTransfer = await page.evaluateHandle(() => new DataTransfer());
await firstSection.locator('.section-drag-handle').dispatchEvent('dragstart', {
dataTransfer: sectionTransfer
});
await page.locator('.run-section').nth(1).dispatchEvent('dragover', {
dataTransfer: sectionTransfer
});
await expect(page.locator('[data-drop-preview="section"]')).toContainText('Start');
await expect(firstSection).toHaveClass(/is-dragging/);
await firstSection.locator('.section-drag-handle').dispatchEvent('dragend', {
dataTransfer: sectionTransfer
});
await sectionTransfer.dispose();
await expect(page.locator('[data-drop-preview="section"]')).toHaveCount(0);
await page.getByRole('button', { name: 'Pieces auswählen' }).click();
await page.getByPlaceholder('Pieces durchsuchen').fill('kleine Konsole');
await page.getByRole('button', { name: 'Filtern' }).click();
const source = page
.locator('.builder-source-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' });
await expect(page.locator('.builder-source-card')).toHaveCount(1);
const reactionSection = page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) });
await page.locator('body').evaluate((body) => (body.dataset.builderDocument = 'stable'));
const sourceDragHandle = source.locator('.source-drag-copy');
const emptyReactionTarget = reactionSection.locator('.section-placeholder');
const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
await sourceDragHandle.dispatchEvent('dragstart', { dataTransfer });
await emptyReactionTarget.dispatchEvent('dragover', { dataTransfer });
const insertionPreview = reactionSection.locator('[data-drop-preview="usage"]');
await expect(insertionPreview).toContainText('Die kleine Konsole mit dem großen Dreh');
await expect(source).toHaveClass(/is-dragging/);
await expect(source).toHaveCSS('opacity', '0.45');
await emptyReactionTarget.dispatchEvent('drop', { dataTransfer });
await dataTransfer.dispose();
await expect(reactionSection.locator('.editable-usage')).toHaveCount(1);
await expect(page.locator('body')).toHaveAttribute('data-builder-document', 'stable');
await expect(source).toHaveCount(0);
await page.getByLabel('Auch eingeplante Inhalte zeigen').check();
await expect(source).toContainText('Eingeplant');
await sourceDragHandle.dragTo(reactionSection.locator('.editable-usage'));
let usages = page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) })
.locator('.editable-usage');
await expect(usages).toHaveCount(2);
await usages
.nth(0)
.getByRole('button', {
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
})
.click();
const streamInspector = page.locator('.stream-inspector');
await expect(streamInspector).toContainText('Streamfassung');
await expect(streamInspector.locator('.stream-inspector-form')).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('verwendungseditor-desktop.png'),
fullPage: true
});
await streamInspector.getByText('Weitere Aktionen', { exact: true }).click();
await streamInspector.getByRole('button', { name: 'Aus Piece aktualisieren' }).click();
usages = page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) })
.locator('.editable-usage');
await usages
.nth(0)
.getByRole('button', {
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
})
.click();
let usageDialog = page.locator('.stream-inspector');
await usageDialog.getByLabel('Interner Titel').fill('Konsole als Auftakt');
await usageDialog.getByLabel('Dauer in Minuten').fill('5');
await usageDialog
.getByRole('textbox', { name: 'Eigene Notizen', exact: true })
.fill('Kurbel zuerst zeigen');
await usageDialog.getByRole('button', { name: 'Speichern', exact: true }).click();
await expect(reactionSection).toContainText('Konsole als Auftakt');
usages = page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) })
.locator('.editable-usage');
await usages
.nth(1)
.getByRole('button', {
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
})
.click();
usageDialog = page.locator('.stream-inspector');
await usageDialog.getByLabel('Interner Titel').fill('Konsole als Rückblick');
await usageDialog.getByLabel('Dauer in Minuten').fill('15');
await usageDialog
.getByRole('textbox', { name: 'Eigene Notizen', exact: true })
.fill('Andere Perspektive');
await usageDialog.getByRole('button', { name: 'Speichern', exact: true }).click();
await expect(page.locator('.show-summary')).toContainText('2 Std. 25 Min.');
usages = page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) })
.locator('.editable-usage');
await usages
.nth(1)
.getByRole('button', { name: 'Konsole als Rückblick bearbeiten und verschieben' })
.click();
await streamInspector.getByText('Weitere Aktionen', { exact: true }).click();
await streamInspector.getByRole('button', { name: 'Duplizieren' }).click();
await expect(reactionSection.locator('.editable-usage')).toHaveCount(3);
await expect(reactionSection.locator('.editable-usage').nth(2)).toContainText(
'Konsole als Rückblick'
);
await expect(page.locator('.show-summary')).toContainText('2 Std. 40 Min.');
const planTimeBeforeReserve = await page.locator('.show-summary').textContent();
await page.getByRole('button', { name: 'Pieces auswählen' }).click();
await source.getByLabel(/Ziel für/).selectOption('');
await source.getByRole('button', { name: 'Hinzufügen' }).click();
await expect(page.locator('.reserve-usage')).toHaveCount(1);
expect(await page.locator('.show-summary').textContent()).toBe(planTimeBeforeReserve);
await page
.locator('.reserve-usage')
.getByRole('button', {
name: 'Die kleine Konsole mit dem großen Dreh bearbeiten und verschieben'
})
.click();
await expect(streamInspector).toContainText('Streamfassung');
await streamInspector.getByText('Weitere Aktionen', { exact: true }).click();
await expect(streamInspector.getByRole('button', { name: 'Duplizieren' })).toBeVisible();
await streamInspector.getByRole('button', { name: 'Streamdaten' }).click();
const moveUp = page.getByRole('button', { name: 'Konsole als Rückblick nach oben' }).first();
await moveUp.focus();
await page.keyboard.press('Enter');
await expect(
page
.locator('.run-section')
.filter({ has: page.getByRole('heading', { name: 'JC / Reaction' }) })
.locator('.editable-usage strong')
.first()
).toHaveText('Konsole als Rückblick');
const reserveUsage = page.locator('.reserve-usage');
const reserveHandle = reserveUsage.locator('.usage-drag-copy');
const finalSectionTarget = page.locator('.run-section').last().locator('.section-placeholder');
const reserveTransfer = await page.evaluateHandle(() => new DataTransfer());
await reserveHandle.dispatchEvent('dragstart', { dataTransfer: reserveTransfer });
await finalSectionTarget.dispatchEvent('dragover', { dataTransfer: reserveTransfer });
await expect(page.locator('[data-drop-preview="usage"]')).toContainText(
'Die kleine Konsole mit dem großen Dreh'
);
await expect(reserveUsage).toHaveClass(/is-dragging/);
await finalSectionTarget.dispatchEvent('drop', { dataTransfer: reserveTransfer });
await reserveTransfer.dispose();
await expect(page.locator('.reserve-usage')).toHaveCount(0);
await expect(page.locator('body')).toHaveAttribute('data-builder-document', 'stable');
await page.reload();
await expect(page.locator('.editable-usage')).toHaveCount(4);
const savedUsage = page.locator('.editable-usage').filter({ hasText: 'Konsole als Auftakt' });
await savedUsage
.getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' })
.click();
await expect(page.locator('.stream-inspector [name="ownNotes"]')).toHaveValue(
'Kurbel zuerst zeigen'
);
await streamInspector.getByRole('button', { name: 'Streamdaten' }).click();
const duplicatedUsage = page
.locator('.editable-usage')
.filter({ hasText: 'Konsole als Rückblick' });
await expect(duplicatedUsage).toHaveCount(2);
await duplicatedUsage
.nth(1)
.getByRole('button', { name: 'Konsole als Rückblick bearbeiten und verschieben' })
.click();
await expect(streamInspector.getByLabel('Dauer in Minuten')).toHaveValue('15');
await expect(page.locator('.stream-inspector [name="ownNotes"]')).toHaveValue(
'Andere Perspektive'
);
await streamInspector.getByRole('button', { name: 'Streamdaten' }).click();
await page.setViewportSize({ width: 390, height: 844 });
await savedUsage
.getByRole('button', { name: 'Konsole als Auftakt bearbeiten und verschieben' })
.click();
await expect(streamInspector.locator('.stream-inspector-form')).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('verwendungseditor-mobile.png'),
fullPage: true
});
await streamInspector.getByText('Weitere Aktionen', { exact: true }).click();
await streamInspector
.getByRole('button', { name: 'Aus Stream entfernen' })
.scrollIntoViewIfNeeded();
await expect(streamInspector.getByRole('button', { name: 'Aus Stream entfernen' })).toBeVisible();
await page
.locator('.stream-inspector form[action="?/updateUsageMetadata"] input[name="revision"]')
.evaluate((element) => ((element as HTMLInputElement).value = '1'));
await streamInspector.getByRole('button', { name: 'Speichern', exact: true }).click();
await expect(page.getByRole('alert')).toContainText('inzwischen geändert');
await page.getByRole('button', { name: 'Ablauf', exact: true }).click();
await page.getByPlaceholder('Neuer Abschnitt').fill('Finales Spiel');
await page.getByRole('button', { name: 'Abschnitt hinzufügen' }).click();
await expect(page.getByRole('heading', { name: 'Finales Spiel' })).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('sendungsbau-mobile.png'), fullPage: true });
await page.goto('/fundus?q=kleine+Konsole&includePlanned=1');
await expect(
page
.locator('.piece-card')
.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible();
await page.goto(showUrl);
});
test('all work areas fit at required widths in light and dark mode', async ({ page }, testInfo) => {
for (const viewport of viewports) {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto('/fundus');
await setTheme(page, viewport.theme);
for (const route of routes) {
await page.goto(route);
await expect(page.locator('html')).toHaveAttribute('data-theme', viewport.theme);
await expectNoHorizontalOverflow(page);
}
}
await page.setViewportSize({ width: 768, height: 900 });
await page.goto('/fundus');
await setTheme(page, 'dark');
await page.goto('/verwaltung');
await page.screenshot({
path: testInfo.outputPath('verwaltung-tablet-dark.png'),
fullPage: true
});
});
test('long titles and longer fundus lists reflow without overflow', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus');
await expect(page.locator('.piece-card')).toHaveCount(1);
await page.evaluate(() => {
const list = document.querySelector('.piece-grid');
const rows = Array.from(document.querySelectorAll('.piece-card'));
const title = rows[0]?.querySelector('h2');
if (title) {
title.textContent =
'Ein außergewöhnlich langer redaktioneller Titel über kleine Konsolen, große Ideen und unerwartete Wendungen';
}
for (let repetition = 0; repetition < 3; repetition += 1) {
for (const row of rows) list?.append(row.cloneNode(true));
}
});
await expect(page.locator('.piece-card')).toHaveCount(4);
await expectNoHorizontalOverflow(page);
await page.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(page);
await expect(page.locator('.piece-card').first()).toHaveCSS('flex-direction', 'column');
const mobileCard = await page.locator('.piece-card').first().boundingBox();
const mobilePrimary = await page.locator('.piece-primary').first().boundingBox();
expect(mobilePrimary?.width ?? 0).toBeGreaterThan((mobileCard?.width ?? 0) - 40);
await expect(page.locator('.piece-card').first().getByRole('heading')).toContainText(
'Ein außergewöhnlich langer redaktioneller Titel'
);
await page.screenshot({
path: testInfo.outputPath('fundus-mobile-long-content.png'),
fullPage: true
});
});
test('inbox, archive and administration stay concise', async ({ page }, testInfo) => {
for (const [width, height] of [
[390, 844],
[960, 900],
[1440, 1000]
] as const) {
await page.setViewportSize({ width, height });
await page.goto('/eingang');
await expect(page.locator('.capture-panel')).toBeVisible();
await expect(page.locator('.inbox-catalog-row')).toHaveCount(1);
await expect(page.locator('.content-quick-inspector')).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath(`inbox-overview-${width}.png`),
fullPage: true
});
await page.goto('/archiv');
await expect(page.locator('.archive-list article')).toHaveCount(1);
await expectNoHorizontalOverflow(page);
}
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/verwaltung');
const panels = page.locator('.taxonomy-panel');
await expect(panels).toHaveCount(3);
const boxes = await Promise.all([
panels.nth(0).boundingBox(),
panels.nth(1).boundingBox(),
panels.nth(2).boundingBox()
]);
expect(boxes[0]?.y).toBe(boxes[1]?.y);
expect(boxes[1]?.y).toBe(boxes[2]?.y);
const topicIconPicker = page.locator('.topic-management-panel .taxonomy-form .icon-picker');
for (const width of [1280, 960, 390]) {
await page.setViewportSize({ width, height: 900 });
await topicIconPicker.getByText('Icon auswählen', { exact: true }).click();
const popover = topicIconPicker.locator('.icon-picker-popover');
await expect(popover).toBeVisible();
await expect(topicIconPicker.getByPlaceholder('Icon suchen')).toBeFocused();
const popoverBox = await popover.boundingBox();
expect(popoverBox?.x ?? -1).toBeGreaterThanOrEqual(0);
expect((popoverBox?.x ?? width) + (popoverBox?.width ?? 1)).toBeLessThanOrEqual(width);
await topicIconPicker.getByText('Icon auswählen', { exact: true }).click();
}
await page.setViewportSize({ width: 1280, height: 900 });
await topicIconPicker.getByText('Icon auswählen', { exact: true }).click();
await topicIconPicker.getByPlaceholder('Icon suchen').fill('Satellite Dish');
await topicIconPicker.getByRole('button', { name: 'Satellite Dish', exact: true }).click();
await expect(topicIconPicker.locator('input[type="hidden"]')).toHaveValue('satellite-dish');
});
test('rss selection and integration catalogs keep deliberate choices', async ({
page
}, testInfo) => {
addRssSelectionFixture();
try {
await page.context().route('https://example.org/**', (route) =>
route.fulfill({
contentType: 'text/html',
body: '<!doctype html><title>RSS-Original</title>'
})
);
await page.setViewportSize({ width: 1100, height: 760 });
await setTheme(page, 'light');
await page.goto('/eingang');
const rssPanel = page.locator('.rss-inbox-panel');
await expect(rssPanel).toContainText('45 Beiträge sind verfügbar');
await page.getByRole('button', { name: 'RSS-Beiträge auswählen' }).click();
const rssDialog = page.getByRole('dialog', { name: 'RSS-Beiträge auswählen' });
await expect(rssDialog).toBeVisible();
await expect(rssDialog.getByRole('option')).toHaveCount(20);
await rssDialog.getByRole('button', { name: 'E2E Quelle B' }).click();
await expect(rssDialog).toContainText('25 verfügbare Beiträge');
const firstChoice = rssDialog.getByRole('option', { name: /E2E Quelle A Beitrag 25/ });
await firstChoice.press('Enter');
await expect(firstChoice).toHaveAttribute('aria-selected', 'true');
const [original] = await Promise.all([
page.waitForEvent('popup'),
firstChoice.getByRole('link', { name: 'Original öffnen' }).click()
]);
await original.close();
await expect(firstChoice).toHaveAttribute('aria-selected', 'true');
await rssDialog.getByRole('button', { name: 'Weiter' }).click();
const secondChoice = rssDialog.getByRole('option', { name: /E2E Quelle A Beitrag 1/ });
await secondChoice.press(' ');
await expect(secondChoice).toHaveAttribute('aria-selected', 'true');
await rssDialog.getByRole('button', { name: 'E2E Quelle B' }).click();
await expect(
rssDialog.getByRole('button', { name: 'Auswahl in den Eingang übernehmen (2)' })
).toBeEnabled();
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => (document.documentElement.dataset.theme = 'dark'));
await expectNoHorizontalOverflow(page);
await expect(
rssDialog.getByRole('button', { name: 'Auswahl in den Eingang übernehmen (2)' })
).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('rss-selection-mobile-dark.png') });
await rssDialog.getByRole('button', { name: 'Auswahl in den Eingang übernehmen (2)' }).click();
await expect(page.getByRole('status')).toContainText('2 Beiträge in den Eingang übernommen');
await expect(rssPanel).toContainText('43 Beiträge sind verfügbar');
await expect(page.getByText('E2E Quelle A Beitrag 25', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'RSS-Beiträge auswählen' }).click();
await expect(rssDialog.getByText('E2E Quelle A Beitrag 25', { exact: true })).toHaveCount(0);
await expect(rssDialog.getByText('E2E Quelle A Beitrag 1', { exact: true })).toHaveCount(0);
await rssDialog.getByRole('button', { name: 'RSS-Auswahl schließen' }).click();
await page.setViewportSize({ width: 1100, height: 800 });
await setTheme(page, 'light');
await page.goto('/verwaltung');
await page.getByRole('button', { name: 'Feed hinzufügen' }).click();
let feedDialog = page.getByRole('dialog', { name: 'Feed hinzufügen' });
await feedDialog.getByLabel('Name').fill('E2E Feed Verwaltung');
await feedDialog.getByLabel('Feed-URL').fill('https://example.org/e2e-admin.xml');
await feedDialog.getByLabel('Intervall in Minuten').fill('90');
await feedDialog.getByLabel('Standardkategorie').selectOption({ index: 1 });
await feedDialog.getByRole('button', { name: 'Feed anlegen' }).click();
const feedEntry = page.getByRole('button', { name: 'E2E Feed Verwaltung bearbeiten' });
await expect(feedEntry).toBeVisible();
await feedEntry.click();
feedDialog = page.getByRole('dialog', { name: 'E2E Feed Verwaltung' });
await feedDialog.getByLabel('Name').fill('E2E Feed Verwaltung bearbeitet');
await feedDialog.getByLabel('Intervall in Minuten').fill('120');
await feedDialog.getByRole('button', { name: 'Änderungen speichern' }).click();
await expect(
page.getByRole('button', { name: 'E2E Feed Verwaltung bearbeitet bearbeiten' })
).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => (document.documentElement.dataset.theme = 'dark'));
await page.getByRole('button', { name: 'E2E Feed Verwaltung bearbeitet bearbeiten' }).click();
feedDialog = page.getByRole('dialog', { name: 'E2E Feed Verwaltung bearbeitet' });
await expect(feedDialog.getByRole('button', { name: 'Änderungen speichern' })).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('feed-editor-mobile-dark.png') });
await feedDialog.getByRole('button', { name: 'Feeddialog schließen' }).click();
await page.setViewportSize({ width: 1100, height: 800 });
await page.evaluate(() => (document.documentElement.dataset.theme = 'light'));
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await page.getByRole('button', { name: 'Zugang hinzufügen' }).click();
let accessDialog = page.getByRole('dialog', { name: 'API-Zugang hinzufügen' });
await accessDialog.getByLabel('Name').fill('E2E API-Zugang');
await accessDialog.getByLabel('Berechtigung').selectOption('read_write');
await accessDialog.getByRole('button', { name: 'Zugang anlegen' }).click();
await expect(accessDialog.getByText('Token, einmalig sichtbar')).toBeVisible();
const token = await accessDialog.locator('code').textContent();
expect(token?.length ?? 0).toBeGreaterThan(20);
await accessDialog.getByRole('button', { name: 'Token kopieren' }).click();
await expect(accessDialog.getByText('Token kopiert.')).toBeVisible();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(token);
await accessDialog.getByRole('button', { name: 'Zugangsdialog schließen' }).click();
await page.getByRole('button', { name: 'E2E API-Zugang bearbeiten' }).click();
accessDialog = page.getByRole('dialog', { name: 'E2E API-Zugang' });
await accessDialog.getByLabel('Name').fill('E2E API-Zugang bearbeitet');
await accessDialog.getByLabel('Berechtigung').selectOption('read');
await accessDialog.getByRole('button', { name: 'Änderungen speichern' }).click();
await expect(
page.getByRole('button', { name: 'E2E API-Zugang bearbeitet bearbeiten' })
).toContainText('Nur lesen');
await page.screenshot({
path: testInfo.outputPath('integration-catalogs-light.png'),
fullPage: true
});
} finally {
removeIntegrationFixtures();
}
});
test('tools stay editable in pieces after catalog deactivation', async ({ page }, testInfo) => {
await page.goto('/verwaltung');
for (const width of [390, 1280]) {
await page.setViewportSize({ width, height: 900 });
await expect(page.locator('.tool-editor-dialog')).toBeHidden();
await expectNoHorizontalOverflow(page);
}
await page.getByRole('button', { name: 'Werkzeug hinzufügen' }).click();
const create = page.getByRole('dialog', { name: 'Neues Werkzeug' });
await create.getByLabel('Name').fill('E2E Tierlist');
await create.getByLabel('Start-URL').fill('https://example.com/tierlist');
await create.getByLabel('Beschreibung').fill('Ranking für die Sendung');
await create.getByText('Icon auswählen', { exact: true }).click();
await create.getByPlaceholder('Icon suchen').fill('Satellite Dish');
await page.screenshot({ path: testInfo.outputPath('tool-icon-picker-desktop.png') });
await create.getByRole('button', { name: 'Satellite Dish', exact: true }).click();
await create.getByText('Formate auswählen', { exact: true }).click();
await create.getByRole('checkbox', { name: 'Quiz' }).check();
await create.getByRole('button', { name: 'Auswahl schließen' }).click();
await page.screenshot({ path: testInfo.outputPath('tool-create-desktop.png'), fullPage: true });
await create.getByRole('button', { name: 'Werkzeug anlegen' }).click();
const tool = page.locator('.tool-settings-list > button').filter({ hasText: 'E2E Tierlist' });
await expect(tool).toContainText('Aktiv');
await expect(tool.locator('.catalog-entry-icon svg')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('tool-catalog-desktop.png'), fullPage: true });
await page.goto('/fundus/piece-quiz');
await openEditorialField(page, 'Private Recherche');
await page.getByText('Werkzeuge auswählen', { exact: true }).click();
const toolChoice = page.getByRole('checkbox', { name: /E2E Tierlist/ });
await expect(toolChoice.locator('xpath=..').locator('svg')).toBeVisible();
await toolChoice.check();
await page.getByLabel('Arbeitslink').fill('https://example.com/tierlist/e2e-board');
await page.getByLabel('Notiz').fill('Vor der Sendung sortieren');
await expect(page.locator('.save-indicator')).toHaveText('Ungespeicherte Änderungen');
await expect(page.locator('.save-indicator')).toHaveText('Gespeichert', { timeout: 10_000 });
const toolsSection = page.locator('.piece-source-editor').filter({ hasText: 'Werkzeuge' });
await toolsSection.scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath('piece-tools-desktop.png') });
await page.goto('/verwaltung');
const storedTool = page
.locator('.tool-settings-list > button')
.filter({ hasText: 'E2E Tierlist' });
await storedTool.click();
const edit = page.getByRole('dialog', { name: 'E2E Tierlist' });
await edit.getByRole('checkbox', { name: /^Aktiv/ }).uncheck();
await page.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(page);
await expect(edit.getByRole('button', { name: 'Änderungen speichern' })).toBeVisible();
await edit.getByText('Icon auswählen', { exact: true }).click();
await edit.getByPlaceholder('Icon suchen').fill('Satellite Dish');
await page.screenshot({ path: testInfo.outputPath('tool-icon-picker-mobile.png') });
await edit.getByRole('button', { name: 'Satellite Dish', exact: true }).click();
await page.screenshot({ path: testInfo.outputPath('tool-edit-mobile.png') });
await edit.getByRole('button', { name: 'Änderungen speichern' }).click();
await expect(storedTool).toContainText('Deaktiviert');
await page.screenshot({ path: testInfo.outputPath('tool-catalog-mobile.png'), fullPage: true });
await page.goto('/fundus/piece-quiz');
await openEditorialField(page, 'Private Recherche');
await expect(page.locator('.tool-assignment-row')).toContainText('E2E Tierlist');
await expect(page.locator('.tool-assignment-row')).toContainText('Deaktiviert');
await expect(page.getByLabel('Arbeitslink')).toHaveValue(
'https://example.com/tierlist/e2e-board'
);
await page.setViewportSize({ width: 390, height: 844 });
await page
.locator('.piece-source-editor')
.filter({ hasText: 'Werkzeuge' })
.scrollIntoViewIfNeeded();
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('piece-tools-mobile.png') });
});
test('live is a read-only Stream projection with local selection', async ({ page }, testInfo) => {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
const databasePath = path.join(dataDirectory, 'e2e.sqlite');
const state = () => {
const database = new Database(databasePath, { readonly: true });
const show = database
.prepare('SELECT status, revision FROM shows WHERE id = ?')
.get('show-october');
const usages = database
.prepare(
`SELECT u.id, u.status, u.position, u.show_section_id AS sectionId
FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
WHERE s.show_id = ? ORDER BY s.position, u.position`
)
.all('show-october');
database.close();
return JSON.stringify({ show, usages });
};
const before = state();
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/live?show=show-october');
await expect(page.locator('.app-header')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Herbstsendung: Kleine Welten' })).toBeVisible();
await expect(page.locator('.live-readonly-state')).toContainText('nur lesen');
await expect(page.locator('.live-view-page form')).toHaveCount(1);
await expect(
page.getByRole('button', { name: /Stream starten|Erledigen|Überspringen|Pausieren|Sortieren/ })
).toHaveCount(0);
const usageButtons = page.locator('.live-view-usage');
await expect(usageButtons).toHaveCount(3);
const second = usageButtons.nth(1);
await second.click();
await expect(page.locator('#live-piece-title')).toHaveText(
'Warum praktische Effekte wieder auffallen'
);
await expect(second).toHaveAttribute('aria-pressed', 'true');
const selectedStyle = await second.evaluate((element) => {
const style = getComputedStyle(element);
return {
radius: Number.parseFloat(style.borderRadius),
border: style.borderColor,
background: style.backgroundColor,
shadow: style.boxShadow
};
});
expect(selectedStyle.radius).toBeGreaterThan(0);
expect(selectedStyle.border).not.toBe('rgba(0, 0, 0, 0)');
expect(selectedStyle.background).not.toBe('rgba(0, 0, 0, 0)');
expect(selectedStyle.shadow).toBe('none');
expect(state()).toBe(before);
await page.getByRole('button', { name: 'Präsentieren', exact: true }).click();
expect(state()).toBe(before);
const desktopColumns = await page.evaluate(() => {
const rundown = document.querySelector('.live-view-rundown')?.getBoundingClientRect();
const content = document.querySelector('.live-view-content')?.getBoundingClientRect();
const details = document.querySelector('.live-view-details')?.getBoundingClientRect();
return Boolean(rundown && content && details && content.x > rundown.x && details.x > content.x);
});
expect(desktopColumns).toBe(true);
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('live-readonly-desktop.png'), fullPage: true });
const rejectedWrite = await page.request.post('/live?show=show-october&/start', {
form: { showId: 'show-october', revision: '0', commandId: 'e2e-forbidden' }
});
expect(rejectedWrite.status()).toBeGreaterThanOrEqual(400);
expect(state()).toBe(before);
await page.setViewportSize({ width: 960, height: 900 });
await page.reload();
await expect(page.locator('.live-view-switch')).toBeHidden();
await expect(page.locator('.live-view-rundown')).toBeVisible();
await expect(page.locator('.live-view-content')).toBeVisible();
await expect(page.locator('.live-view-details')).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('live-readonly-compact.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator('.mobile-navigation')).toBeVisible();
await page.getByRole('button', { name: 'Details', exact: true }).click();
await expect(page.locator('.live-view-details')).toBeVisible();
await page.getByRole('button', { name: 'Schrift vergrößern' }).click();
await page.getByRole('button', { name: 'Inhalt', exact: true }).click();
await expect(page.locator('.live-view-content')).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('live-readonly-mobile.png'), fullPage: true });
expect(state()).toBe(before);
});
test('completion, archive and protected guest sharing work end to end', async ({
page,
browser
}, testInfo) => {
addCompletionFixture();
try {
await page.goto('/sendungen/e2e-completion');
await page.getByRole('button', { name: 'Lesefreigaben' }).click();
const planningShares = page.locator('.share-management');
await expect(planningShares.getByText('Aktualisiert sich bis zum Abschluss')).toBeVisible();
await planningShares.getByLabel('Informationsumfang').selectOption('moderation');
await planningShares.getByRole('button', { name: 'Leselink anlegen' }).click();
const planningSharePath = await planningShares.locator('.share-result a').getAttribute('href');
expect(planningSharePath).toMatch(/^\/freigabe\//);
await page.screenshot({
path: testInfo.outputPath('share-editor-desktop.png'),
fullPage: true
});
await page.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('share-editor-mobile.png'), fullPage: true });
await page.setViewportSize({ width: 1280, height: 900 });
const planningGuest = await browser.newContext();
const planningGuestPage = await planningGuest.newPage();
await planningGuestPage.goto(planningSharePath!);
await expect(planningGuestPage.getByText('Aktuelle Streamplanung · Nur lesen')).toBeVisible();
await expect(planningGuestPage.getByText('Gesendeter Abschlussbeitrag')).toBeVisible();
await expect(planningGuestPage.getByText('FREIGABE_MODERATION')).toBeVisible();
const guestThemePreference = await planningGuestPage
.locator('html')
.getAttribute('data-theme-preference');
await planningGuestPage.getByRole('button', { name: /Darstellung:/ }).click();
await expect(planningGuestPage.locator('html')).not.toHaveAttribute(
'data-theme-preference',
guestThemePreference!
);
const planningShareRow = planningShares.locator('.share-row').first();
await planningShareRow.locator('summary').getByText('Ablauf und Moderation').click();
await planningShareRow.getByLabel('Informationsumfang').selectOption('research');
await planningShareRow.locator('select[name="passwordMode"]').selectOption('replace');
await planningShareRow.locator('input[name="password"]').fill('Planung-Freigabe-Passwort');
await planningShareRow.getByRole('button', { name: 'Freigabe speichern' }).click();
await planningGuestPage.reload();
await expect(
planningGuestPage.getByRole('heading', { name: 'Passwort erforderlich' })
).toBeVisible();
page.once('dialog', (dialog) => dialog.accept());
await planningShares
.locator('.share-row')
.first()
.locator('summary')
.getByText('Vollständig mit Recherche')
.click();
await planningShares.getByRole('button', { name: 'Freigabe löschen' }).click();
await planningGuestPage.reload();
await expect(planningGuestPage.getByRole('heading', { name: 'Nicht verfügbar' })).toBeVisible();
await planningGuest.close();
finalizeCompletionFixture();
await page.goto('/sendungen?state=completed');
await expect(
page.locator('.show-card-list').getByRole('link', { name: /E2E Abschluss/ })
).toBeVisible();
await page
.locator('.show-card-list')
.getByRole('link', { name: /E2E Abschluss/ })
.click();
await expect(page.getByText('Unveränderliche Streamhistorie')).toBeVisible();
await expect(page.getByText('Gesendeter Abschlussbeitrag')).toBeVisible();
await expect(page.getByText('Übersprungener Abschlussbeitrag')).toBeVisible();
await expect(page.getByRole('button', { name: 'Streamdaten speichern' })).toHaveCount(0);
expect(
await page
.locator('.historical-section')
.first()
.evaluate((element) => Number.parseFloat(getComputedStyle(element).paddingInlineStart))
).toBeGreaterThan(0);
await page.goto('/verwaltung');
const shareCatalog = page.locator('.share-catalog-panel');
await expect(shareCatalog).toBeVisible();
const catalogOrder = await page.evaluate(() => {
const share = document.querySelector('.share-catalog-panel')?.getBoundingClientRect();
const tools = document.querySelector('.tool-management')?.getBoundingClientRect();
return share && tools ? share.bottom <= tools.top : false;
});
expect(catalogOrder).toBe(true);
await page.getByRole('button', { name: 'Lesefreigaben' }).click();
const shareManagement = page.locator('.share-management');
await expect(page.locator('.share-management-dialog')).toBeVisible();
const dialogStyle = await page.locator('.share-management-dialog').evaluate((element) => ({
borderRadius: Number.parseFloat(getComputedStyle(element).borderRadius),
background: getComputedStyle(element).backgroundColor
}));
expect(dialogStyle.borderRadius).toBeGreaterThan(0);
expect(dialogStyle.background).not.toBe('rgba(0, 0, 0, 0)');
await shareManagement.getByLabel('Stream').selectOption('e2e-completion');
await shareManagement.getByLabel('Informationsumfang').selectOption('moderation');
await shareManagement.getByLabel('Optionales Passwort').fill('E2E-Freigabe-Passwort');
await shareManagement.getByRole('button', { name: 'Leselink anlegen' }).click();
const shareLink = page.locator('.share-result a');
await expect(shareLink).toBeVisible();
const sharePath = await shareLink.getAttribute('href');
expect(sharePath).toMatch(/^\/freigabe\//);
await page.screenshot({ path: testInfo.outputPath('share-admin-desktop.png'), fullPage: true });
const guest = await browser.newContext();
const guestPage = await guest.newPage();
await guestPage.goto(sharePath!);
await expect(guestPage.getByRole('heading', { name: 'Passwort erforderlich' })).toBeVisible();
const passwordHtml = await guestPage.content();
expect(passwordHtml).not.toContain('password_hash');
expect(passwordHtml).not.toContain('e2e-completion');
await guestPage.getByLabel('Passwort').fill('falsch');
await guestPage.getByRole('button', { name: 'Stream öffnen' }).click();
await expect(guestPage.getByRole('alert')).toContainText('Passwort falsch');
await guestPage.getByLabel('Passwort').fill('E2E-Freigabe-Passwort');
await guestPage.getByRole('button', { name: 'Stream öffnen' }).click();
await expect(guestPage.getByRole('heading', { name: 'E2E Abschluss' })).toBeVisible();
await expect(guestPage.getByText('FREIGABE_MODERATION')).toBeVisible();
await expect(guestPage.getByText('PRIVATE_FREIGABE_RECHERCHE')).toHaveCount(0);
await expect(guestPage.getByText('PRIVATE_FREIGABE_NOTIZ')).toHaveCount(0);
await expect(guestPage.getByAltText('Freigegebenes Abschlussbild')).toBeVisible();
expect(
await guestPage
.locator('.shared-section')
.first()
.evaluate((element) => Number.parseFloat(getComputedStyle(element).paddingInlineStart))
).toBeGreaterThan(0);
await guestPage.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(guestPage);
await guestPage.screenshot({ path: testInfo.outputPath('share-mobile.png'), fullPage: true });
const imagePath = await guestPage
.getByAltText('Freigegebenes Abschlussbild')
.getAttribute('src');
const sharedImage = await guest.request.get(imagePath!);
expect(sharedImage.status()).toBe(200);
expect(sharedImage.headers()['content-security-policy']).toBe("default-src 'none'; sandbox");
expect((await guest.request.get(`${sharePath}/media/foreign-id`)).status()).toBe(404);
const guestWrite = await guest.request.post('/sendungen/e2e-completion?/saveMetadata', {
maxRedirects: 0,
form: { title: 'Verboten', revision: '2', status: 'draft' }
});
expect(guestWrite.status()).toBe(403);
await page.goto('/verwaltung');
await page.getByRole('button', { name: 'Lesefreigaben' }).click();
const showCard = page.locator('.share-show-card').filter({ hasText: 'E2E Abschluss' });
await expect(showCard.getByText('Abgeschlossen')).toBeVisible();
const shareRow = showCard.locator('.share-row').first();
await shareRow.locator('summary').getByText('Ablauf und Moderation').click();
await shareRow.getByLabel('Informationsumfang').selectOption('rundown');
await shareRow.locator('select[name="passwordMode"]').selectOption('remove');
await shareRow.getByRole('button', { name: 'Freigabe speichern' }).click();
await guestPage.reload();
await expect(guestPage.getByRole('heading', { name: 'E2E Abschluss' })).toBeVisible();
await expect(guestPage.getByText('FREIGABE_MODERATION')).toHaveCount(0);
page.once('dialog', (dialog) => dialog.accept());
const updatedCard = page.locator('.share-show-card').filter({ hasText: 'E2E Abschluss' });
await updatedCard
.locator('.share-row')
.first()
.locator('summary')
.getByText('Nur Ablauf')
.click();
await updatedCard.getByRole('button', { name: 'Freigabe löschen' }).click();
await guestPage.reload();
await expect(guestPage.getByRole('heading', { name: 'Nicht verfügbar' })).toBeVisible();
expect((await guest.request.get(imagePath!)).status()).toBe(404);
await guest.close();
} finally {
removeCompletionFixture();
}
});
test('piece writing room stays focused across content, themes and narrow screens', async ({
page
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await setTheme(page, 'light');
await page.goto('/fundus/piece-console');
const writing = page.locator('.piece-writing-stack');
const context = page.locator('.piece-meta-details');
await expect(writing).toBeVisible();
await expect(context).toBeVisible();
await expect(page.locator('.app-header .brand-version')).toHaveText('0.9.0');
await expect(page.locator('.fundus-navigation')).toHaveCount(0);
const pieceDialog = page.getByRole('dialog', { name: /Die kleine Konsole/ });
await expect(pieceDialog).toBeVisible();
await pieceDialog.evaluate(async (element) => {
await Promise.all(
element.getAnimations({ subtree: true }).map((animation) => animation.finished)
);
});
const titleRowBox = await page.locator('.piece-editor-title-row').boundingBox();
const headerActionsBox = await page.locator('.piece-editor-header-actions').boundingBox();
expect(headerActionsBox?.y).toBeGreaterThanOrEqual(
(titleRowBox?.y ?? 0) + (titleRowBox?.height ?? 0)
);
const workspaceBox = await page.locator('.piece-dialog-workspace').boundingBox();
const writingBox = await writing.boundingBox();
const contextBox = await context.boundingBox();
const presentationBox = await page.locator('.piece-presentation-panel').boundingBox();
expect((writingBox?.x ?? 0) - (workspaceBox?.x ?? 0)).toBeLessThanOrEqual(18);
expect(
(contextBox?.x ?? 0) - ((writingBox?.x ?? 0) + (writingBox?.width ?? 0))
).toBeLessThanOrEqual(18);
const firstWritingFieldBox = await page
.locator('.piece-writing-stack .rich-markdown-field')
.first()
.boundingBox();
expect(firstWritingFieldBox?.height).toBeLessThan(170);
expect(contextBox?.height).toBeLessThan(500);
expect(presentationBox?.y).toBeLessThan(800);
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('piece-editor-desktop-expanded-light.png'),
fullPage: true
});
await setTheme(page, 'dark');
await page.reload();
await expect(writing).toBeVisible();
await page.screenshot({
path: testInfo.outputPath('piece-editor-desktop-overlay-dark.png'),
fullPage: true
});
await page.setViewportSize({ width: 960, height: 900 });
const narrowDialogBox = await page.locator('.piece-editor-dialog').boundingBox();
expect(narrowDialogBox?.width ?? 0).toBeGreaterThanOrEqual(850);
expect(narrowDialogBox?.width ?? 960).toBeLessThan(960);
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('piece-editor-narrow-overlay-dark.png'),
fullPage: true
});
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/fundus');
await page.locator('.template-picker > summary').click();
await page.getByRole('button', { name: /Leer starten/ }).click();
await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/);
await expect(page.getByRole('textbox', { name: 'Einleitung', exact: true })).toBeEmpty();
await page.screenshot({
path: testInfo.outputPath('piece-editor-empty-desktop-dark.png'),
fullPage: true
});
const emptyPieceUrl = page.url();
const emptyIntroField = page.locator('.rich-markdown-field:has(input[name="intro"])');
const compactIntroBox = await emptyIntroField.boundingBox();
await emptyIntroField
.getByRole('textbox', { name: 'Einleitung', exact: true })
.fill(Array.from({ length: 8 }, (_, index) => `Absatz ${index + 1}`).join('\n\n'));
const expandedIntroBox = await emptyIntroField.boundingBox();
expect(expandedIntroBox?.height).toBeGreaterThan((compactIntroBox?.height ?? 0) + 80);
const introEditor = emptyIntroField.getByRole('textbox', { name: 'Einleitung', exact: true });
await introEditor.fill('Formatierter Einstieg');
await introEditor.press('ControlOrMeta+a');
await expect(emptyIntroField.getByRole('toolbar')).toBeVisible();
await emptyIntroField.getByRole('button', { name: 'Fett' }).click();
await expect(emptyIntroField.locator('input[name="intro"]')).toHaveValue(
'**Formatierter Einstieg**'
);
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
const closeButton = page.getByRole('button', { name: 'Editor schließen' });
await page.locator('dialog.piece-editor-dialog').evaluate((element) => {
sessionStorage.removeItem('piece-editor-close-animation');
element.addEventListener('animationstart', (event) => {
if (event.animationName === 'surface-close') {
sessionStorage.setItem('piece-editor-close-animation', 'started');
}
});
element.addEventListener('animationend', (event) => {
if (event.animationName === 'surface-close') {
sessionStorage.setItem('piece-editor-close-animation', 'finished');
}
});
});
await closeButton.click();
await expect(page).toHaveURL(/\/fundus(?:\?|$)/);
expect(await page.evaluate(() => sessionStorage.getItem('piece-editor-close-animation'))).toBe(
'finished'
);
await page.goto('/fundus?includePlanned=1');
await page
.locator('.piece-card')
.filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
.getByRole('link', { name: 'Die kleine Konsole mit dem großen Dreh', exact: true })
.click();
await page.getByRole('link', { name: 'Vollständigen Piece-Editor öffnen' }).click();
await expect(page).toHaveURL(/\/fundus\/piece-console/);
await page.getByRole('button', { name: 'Editor schließen' }).click();
await page
.locator('.piece-card')
.filter({ hasText: 'Neues Piece' })
.getByRole('link', { name: 'Neues Piece', exact: true })
.click();
await page.getByRole('link', { name: 'Vollständigen Piece-Editor öffnen' }).click();
await expect(page).toHaveURL(
new RegExp(`${emptyPieceUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\?.*)?$`)
);
await expect(page.locator('input[name="intro"]')).toHaveValue('**Formatierter Einstieg**');
await page.setViewportSize({ width: 390, height: 844 });
await page.reload();
const navigation = page.getByRole('navigation', { name: 'Arbeitsbereich' });
await navigation.getByRole('button', { name: 'Streamtext', exact: true }).click();
await page.screenshot({
path: testInfo.outputPath('piece-editor-mobile-streamtext-dark.png'),
fullPage: true
});
await page.getByRole('button', { name: 'Einleitung formatieren' }).click();
await expect(
page.locator('.rich-markdown-field:has(input[name="intro"])').getByRole('toolbar')
).toBeVisible();
await navigation.getByRole('button', { name: 'Details & Ausgabe' }).click();
await expect(page.locator('#piece-editor-form').getByLabel('Status')).toBeVisible();
await expect(page.getByRole('radio', { name: 'Text', exact: true })).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('piece-editor-mobile-dark.png'),
fullPage: true
});
await navigation.getByRole('button', { name: 'Recherche & Quellen' }).click();
await expect(page.getByRole('textbox', { name: 'Private Recherche', exact: true })).toBeVisible();
await expect(page.locator('#piece-editor-form').getByLabel('Status')).not.toBeVisible();
});
test('pieces autosave safely, detect conflicts and protect uploaded images', async ({
page,
browser
}, testInfo) => {
test.setTimeout(45_000);
await page.setViewportSize({ width: 1280, height: 1000 });
await page.goto('/fundus');
await page.locator('.template-picker > summary').click();
await page.getByRole('button', { name: /Reaction \/ Fundstück/ }).click();
await expect(page).toHaveURL(/\/fundus\/[a-f0-9-]+$/);
const pieceUrl = page.url();
const writingBox = await page.locator('.piece-writing-stack').first().boundingBox();
const metadataBox = await page.locator('.piece-meta-details').boundingBox();
expect(writingBox?.x).toBeLessThan(metadataBox?.x ?? 0);
await expectNoHorizontalOverflow(page);
await editInlineTitle(page, 'Neues Fundstück', 'E2E Fundstück mit Markdown');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.getByRole('button', { name: 'Editor schließen' }).click();
await expect(page).toHaveURL(/\/fundus$/);
await page.getByRole('link', { name: 'E2E Fundstück mit Markdown', exact: true }).click();
await page.getByRole('link', { name: 'Vollständigen Piece-Editor öffnen' }).click();
await expect(page).toHaveURL(pieceUrl);
await openPieceMoreDetails(page);
await page
.getByLabel('Kurzbeschreibung')
.fill('Persistiert nach Neuladen und ist gezielt suchbar.');
await page.getByLabel('Status').selectOption('ready');
await page.getByLabel('Dauer in Minuten').fill('10.25');
await page.getByText('Themen auswählen', { exact: true }).click();
await page.getByLabel('Gaming').check();
await openEditorialField(page, 'Private Recherche');
await fillMarkdownSource(page, 'Private Recherche', 'Nur intern: **Hintergrund prüfen**.');
await openEditorialField(page, 'Moderationsstichpunkte');
await page
.getByRole('textbox', { name: 'Moderationsstichpunkte', exact: true })
.fill('Den Kern in zwei Sätzen erklären.');
await openEditorialField(page, 'Private Recherche');
await expect(page.locator('input[name="researchText"]')).toHaveValue(
'Nur intern: **Hintergrund prüfen**.'
);
await openEditorialField(page, 'Präsentationstext');
await page.getByLabel('Öffentliche Überschrift').fill('Öffentlicher E2E-Titel');
await fillMarkdownSource(
page,
'Öffentlicher Vorspann',
'## Sichere Vorschau\n\n<script>globalThis.e2eXss = true</script> **sichtbar**'
);
await expect(page.locator('input[name="presentationText"]')).toHaveValue(
'## 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')).toHaveText('Ungespeicherte Änderungen');
await expect(page.locator('.save-indicator')).toHaveText('Gespeichert', { timeout: 10_000 });
await page.reload();
await openEditorialField(page, 'Präsentationstext');
await expect(page.locator('.piece-presentation-preview script')).toHaveCount(0);
await expect(page.locator('.piece-presentation-preview')).toContainText('Sichere Vorschau');
await expect(
page.getByRole('button', { name: 'E2E Fundstück mit Markdown Titel bearbeiten' })
).toBeVisible();
await expect(page.getByLabel('Dauer in Minuten')).toHaveValue('10.25');
await expect(page.getByLabel('Öffentliche Überschrift')).toHaveValue('Öffentlicher E2E-Titel');
await openEditorialField(page, 'Private Recherche');
await expect(page.locator('input[name="researchText"]')).toHaveValue(
'Nur intern: **Hintergrund prüfen**.'
);
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
await page.getByLabel('Bild', { exact: true }).setInputFiles({
name: 'redaktionsbild.png',
mimeType: 'image/png',
buffer: png
});
await page.getByLabel('Alternativtext').fill('Ein Testpixel');
await page.getByRole('button', { name: 'Bild hochladen' }).click();
const image = page.locator('.media-grid img');
await expect(image).toHaveCount(1);
await expect(image).toHaveAttribute('alt', 'Ein Testpixel');
const mediaUrl = await image.getAttribute('src');
const anonymous = await browser.newContext();
const anonymousResponse = await anonymous.request.get(new URL(mediaUrl!, page.url()).toString(), {
maxRedirects: 0
});
expect(anonymousResponse.status()).toBe(303);
await anonymous.close();
const concurrent = await page.context().newPage();
await concurrent.goto(pieceUrl);
await editInlineTitle(page, 'E2E Fundstück mit Markdown', 'E2E Fundstück, extern aktualisiert');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await openPieceMoreDetails(concurrent);
await concurrent
.getByLabel('Kurzbeschreibung')
.fill('Diese veraltete Änderung darf nicht gewinnen.');
await expect(concurrent.locator('.save-indicator')).toContainText('neuere Änderung', {
timeout: 10_000
});
await concurrent.reload();
await expect(
concurrent.getByRole('button', { name: 'E2E Fundstück, extern aktualisiert Titel bearbeiten' })
).toBeVisible();
await expect(concurrent.getByLabel('Kurzbeschreibung')).not.toHaveValue(
'Diese veraltete Änderung darf nicht gewinnen.'
);
await concurrent.close();
await page.goto('/fundus?q=extern+aktualisiert');
await expect(page.locator('.piece-card')).toHaveCount(1);
await expect(
page.locator('.piece-card').getByRole('heading', { name: 'E2E Fundstück, extern aktualisiert' })
).toBeVisible();
await page.getByRole('link', { name: 'E2E Fundstück, extern aktualisiert', exact: true }).click();
await page.getByRole('link', { name: 'Vollständigen Piece-Editor öffnen' }).click();
await page.locator('.piece-action-menu > summary').click();
page.once('dialog', (dialog) => dialog.accept());
await page.getByRole('button', { name: 'Piece archivieren' }).click();
await expect(page).toHaveURL(/\/archiv$/);
const archived = page.locator('.archive-list article').filter({ hasText: 'extern aktualisiert' });
await expect(archived).toBeVisible();
await archived.getByRole('button', { name: 'Wiederherstellen' }).click();
await expect(archived).toHaveCount(0);
await page.goto('/fundus?q=extern+aktualisiert');
await expect(page.locator('.piece-card')).toHaveCount(1);
await page.locator('.piece-card .piece-actions > summary').click();
await page.locator('.piece-card').getByRole('button', { name: 'Duplizieren' }).click();
await expect(
page.getByRole('button', {
name: 'Kopie von E2E Fundstück, extern aktualisiert Titel bearbeiten'
})
).toBeVisible();
await page.context().setOffline(true);
await editInlineTitle(
page,
'Kopie von E2E Fundstück, extern aktualisiert',
'Kopie mit unterbrochener Verbindung'
);
await expect(page.locator('.save-indicator')).toContainText('Speichern fehlgeschlagen', {
timeout: 10_000
});
await page.context().setOffline(false);
await page.getByRole('button', { name: 'Erneut speichern' }).click();
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
await page.setViewportSize({ width: 390, height: 844 });
await setTheme(page, 'dark');
await page.reload();
await page
.getByRole('navigation', { name: 'Arbeitsbereich' })
.getByRole('button', {
name: 'Recherche & Quellen'
})
.click();
await expect(page.getByRole('heading', { name: 'Quellen', exact: true })).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('piece-editor-mobile-dark.png'),
fullPage: true
});
});
test('a quickly captured video can be written and previewed before classification', async ({
page
}) => {
await page.context().route('https://www.youtube-nocookie.com/**', (route) =>
route.fulfill({
contentType: 'text/html',
body: '<!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-catalog-row').filter({ hasText: 'Schnelles Video-Fundstück' }).click();
await page.getByRole('link', { name: 'Piece vollständig bearbeiten' }).click();
await expect(page.locator('.source-row').getByLabel('URL')).toHaveValue(
'https://youtu.be/dQw4w9WgXcQ'
);
await expect(page.getByLabel('Status')).toBeVisible();
await openEditorialField(page, 'Private Recherche');
await page
.getByRole('textbox', { name: 'Private Recherche', exact: true })
.fill('Warum funktioniert der Einstieg so unmittelbar?');
await openEditorialField(page, 'Moderationsstichpunkte');
await page
.getByRole('textbox', { name: 'Moderationsstichpunkte', exact: true })
.fill('Erst Clip zeigen, dann die Beobachtung öffnen.');
await page.getByRole('radio', { name: 'Video', exact: true }).check();
await page.getByRole('combobox', { name: 'Video-URL', exact: true }).selectOption('0');
await expect(page.locator('.save-indicator')).toContainText('Gespeichert', { timeout: 10_000 });
const previewPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'In neuem Fenster testen' }).click();
const preview = await previewPromise;
await expect(preview.locator('iframe[title="Schnelles Video-Fundstück"]')).toHaveAttribute(
'src',
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ'
);
await preview.close();
await page.setViewportSize({ width: 390, height: 844 });
await page
.getByRole('navigation', { name: 'Arbeitsbereich' })
.getByRole('button', { name: 'Details & Ausgabe' })
.click();
await expect(page.getByRole('radio', { name: 'Video', exact: true })).toBeVisible();
await openEditorialField(page, 'Private Recherche');
await expect(page.getByRole('textbox', { name: 'Private Recherche', exact: true })).toBeVisible();
await expectNoHorizontalOverflow(page);
});
test('link preview uses one existing source, persists manual fields and isolates its test view', async ({
page
}, testInfo) => {
await page.goto('/fundus/piece-console');
await page.getByRole('radio', { name: 'Linkvorschau', exact: true }).check();
await page.getByRole('combobox', { name: 'Quelle/URL', exact: true }).selectOption('0');
await page.getByLabel('Kartentitel').fill('Manuell korrigierte Linkvorschau');
await page.getByLabel('Anreißer').fill('Ein kurzer manueller Anreißer ohne Vorschaubild.');
await page.getByLabel('Vorschaubild-URL').fill('');
await expect(page.locator('.save-indicator')).toHaveText('Ungespeicherte Änderungen');
await expect(page.locator('.save-indicator')).toHaveText('Gespeichert', { timeout: 10_000 });
await page.reload();
await expect(page.getByRole('radio', { name: 'Linkvorschau', exact: true })).toBeChecked();
await expect(page.getByLabel('Kartentitel')).toHaveValue('Manuell korrigierte Linkvorschau');
const previewPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'In neuem Fenster testen' }).click();
const preview = await previewPromise;
await preview.waitForLoadState('networkidle');
await expect(preview.getByText('Isolierte Testansicht')).toBeVisible();
await expect(
preview.getByRole('heading', { name: 'Manuell korrigierte Linkvorschau' })
).toBeVisible();
await expect(preview.getByText('Ein kurzer manueller Anreißer ohne Vorschaubild.')).toBeVisible();
await expect(preview.locator('.output-link-image')).toHaveCount(0);
await preview.screenshot({
path: testInfo.outputPath('linkvorschau-desktop.png'),
fullPage: true
});
await preview.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(preview);
await preview.screenshot({
path: testInfo.outputPath('linkvorschau-mobile.png'),
fullPage: true
});
await preview.close();
await openEditorialField(page, 'Private Recherche');
await page.locator('.source-row').first().getByLabel('URL').fill('http://127.0.0.1/private');
await expect(page.locator('.save-indicator')).toHaveText('Ungespeicherte Änderungen');
await expect(page.locator('.save-indicator')).toHaveText('Gespeichert', { timeout: 10_000 });
await page.reload();
const blockedPreview = page.waitForResponse((response) =>
response.url().includes('?/loadPreview')
);
await page.getByRole('button', { name: 'Vorschau laden' }).click();
expect((await blockedPreview).status()).toBe(400);
await expect(page.getByRole('alert')).toContainText('Interne oder private Netzwerkziele');
});