2378 lines
106 KiB
TypeScript
2378 lines
106 KiB
TypeScript
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');
|
||
});
|