381 lines
16 KiB
TypeScript
381 lines
16 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test';
|
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
const adminEmail = 'barkeeper@example.test';
|
|
const adminPassword = 'Nur-fuer-E2E-Admin-1234';
|
|
const crewEmail = 'crew@example.test';
|
|
const crewPassword = 'Nur-fuer-E2E-Crew-1234';
|
|
|
|
const routes = ['/fundus', '/sendungen', '/eingang', '/archiv', '/verwaltung', '/live'];
|
|
const viewports = [
|
|
{ width: 390, height: 844, theme: 'light' },
|
|
{ width: 390, height: 844, theme: 'dark' },
|
|
{ width: 768, height: 900, theme: 'light' },
|
|
{ width: 768, height: 900, theme: 'dark' },
|
|
{ width: 1440, height: 1000, theme: 'dark' },
|
|
{ width: 1440, height: 1000, theme: 'light' }
|
|
] as const;
|
|
|
|
async function expectNoHorizontalOverflow(page: Page) {
|
|
const dimensions = await page.evaluate(() => ({
|
|
scrollWidth: document.documentElement.scrollWidth,
|
|
clientWidth: document.documentElement.clientWidth
|
|
}));
|
|
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
|
|
}
|
|
|
|
async function setTheme(page: Page, theme: 'light' | 'dark') {
|
|
await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme);
|
|
}
|
|
|
|
async function login(page: Page, email = adminEmail, password = adminPassword) {
|
|
await page.goto('/login');
|
|
await page.getByLabel('E-Mail').fill(email);
|
|
await page.getByLabel('Passwort', { exact: true }).fill(password);
|
|
await page.getByRole('button', { name: 'Anmelden' }).click();
|
|
await expect(page).toHaveURL(/\/fundus$/);
|
|
}
|
|
|
|
function latestMailLink(subject: string): string {
|
|
const outbox = process.env.SENDEWERK_E2E_OUTBOX_DIR;
|
|
if (!outbox) throw new Error('E2E-Outbox ist nicht konfiguriert.');
|
|
const messages = readdirSync(outbox)
|
|
.sort()
|
|
.map(
|
|
(file) =>
|
|
JSON.parse(readFileSync(path.join(outbox, file), 'utf8')) as {
|
|
subject: string;
|
|
text: string;
|
|
}
|
|
)
|
|
.filter((message) => message.subject === subject);
|
|
const match = messages.at(-1)?.text.match(/https?:\/\/[^\s]+/);
|
|
if (!match) throw new Error(`Keine Testmail mit Betreff ${subject} gefunden.`);
|
|
return match[0];
|
|
}
|
|
|
|
test.beforeAll(async ({ browser }) => {
|
|
const page = await browser.newPage();
|
|
await page.goto('/');
|
|
if (await page.getByRole('heading', { name: 'Ersten Barkeeper anlegen' }).isVisible()) {
|
|
await page.getByLabel('Betreiber-Setup-Token').fill('e2e-only-setup-token-32-characters');
|
|
await page.getByLabel('Name').fill('E2E Barkeeper');
|
|
await page.getByLabel('E-Mail').fill(adminEmail);
|
|
await page.getByLabel('Passwort').fill(adminPassword);
|
|
await page.getByRole('button', { name: 'Barkeeper anlegen' }).click();
|
|
} else {
|
|
await page.getByLabel('E-Mail').fill(adminEmail);
|
|
await page.getByLabel('Passwort').fill(adminPassword);
|
|
await page.getByRole('button', { name: 'Anmelden' }).click();
|
|
}
|
|
await expect(page).toHaveURL(/\/fundus$/);
|
|
const sessionCookie = (await page.context().cookies()).find(
|
|
(cookie) => cookie.name === 'sendewerk_session'
|
|
);
|
|
expect(sessionCookie?.httpOnly).toBe(true);
|
|
expect(sessionCookie?.secure).toBe(false);
|
|
expect(sessionCookie?.sameSite).toBe('Lax');
|
|
await page.goto('/setup');
|
|
await expect(page).toHaveURL(/\/fundus$/);
|
|
await page.getByRole('button', { name: 'Abmelden', exact: true }).click();
|
|
await expect(page).toHaveURL(/\/login$/);
|
|
await page.close();
|
|
});
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await login(page);
|
|
});
|
|
|
|
test('invitations, direct authorization, locking and password reset work end to end', async ({
|
|
page,
|
|
browser
|
|
}, testInfo) => {
|
|
await page.goto('/verwaltung');
|
|
await page.screenshot({
|
|
path: testInfo.outputPath('verwaltung-konten-desktop.png'),
|
|
fullPage: true
|
|
});
|
|
const crossSite = await page.request.post('/verwaltung?/invite', {
|
|
form: { email: 'cross-site@example.test', role: 'barkeeper' },
|
|
headers: { origin: 'https://example.invalid' }
|
|
});
|
|
expect(crossSite.status()).toBe(403);
|
|
const inviteForm = page.locator('.invite-panel').filter({ hasText: 'Neues Konto einladen' });
|
|
await inviteForm.getByLabel('E-Mail').fill(crewEmail);
|
|
await inviteForm.getByRole('button', { name: 'Einladung erstellen' }).click();
|
|
await expect(
|
|
page.getByText('Einladung wurde in der lokalen Mail-Outbox abgelegt.')
|
|
).toBeVisible();
|
|
|
|
const crewContext = await browser.newContext();
|
|
const crewPage = await crewContext.newPage();
|
|
await crewPage.goto(latestMailLink('Einladung zu Sendewerk'));
|
|
await expect(crewPage).toHaveURL(/\/einladung$/);
|
|
await crewPage.getByLabel('Name').fill('E2E Crew');
|
|
await crewPage.getByLabel('Passwort').fill(crewPassword);
|
|
await crewPage.getByRole('button', { name: 'Konto aktivieren' }).click();
|
|
await expect(crewPage).toHaveURL(/\/fundus$/);
|
|
await expect(crewPage.getByText('Verwaltung', { exact: true })).toHaveCount(0);
|
|
|
|
const forbidden = await crewPage.request.post('/verwaltung?/invite', {
|
|
form: { email: 'verboten@example.test', role: 'barkeeper' },
|
|
headers: { origin: 'http://127.0.0.1:4173' }
|
|
});
|
|
expect(forbidden.status()).toBe(403);
|
|
|
|
await page.reload();
|
|
const crewRow = page.locator('.account-row').filter({ hasText: crewEmail });
|
|
await crewRow.getByRole('button', { name: 'Sperren' }).click();
|
|
await crewPage.goto('/fundus');
|
|
await expect(crewPage).toHaveURL(/\/login$/);
|
|
|
|
await page.reload();
|
|
await page
|
|
.locator('.account-row')
|
|
.filter({ hasText: crewEmail })
|
|
.getByRole('button', { name: 'Entsperren' })
|
|
.click();
|
|
await crewPage.goto('/passwort-vergessen');
|
|
await crewPage.getByLabel('E-Mail').fill(crewEmail);
|
|
await crewPage.getByRole('button', { name: 'Reset-Link anfordern' }).click();
|
|
await expect(crewPage.getByText('Anfrage verarbeitet.')).toBeVisible();
|
|
await crewPage.goto(latestMailLink('Sendewerk-Passwort zurücksetzen'));
|
|
await expect(crewPage).toHaveURL(/\/passwort-zuruecksetzen$/);
|
|
await crewPage.getByLabel('Neues Passwort').fill('Neues-E2E-Crew-Passwort-1234');
|
|
await crewPage.getByRole('button', { name: 'Passwort speichern' }).click();
|
|
await login(crewPage, crewEmail, 'Neues-E2E-Crew-Passwort-1234');
|
|
await crewContext.close();
|
|
|
|
const anonymousContext = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
|
const loginPage = await anonymousContext.newPage();
|
|
await loginPage.goto('/login');
|
|
await setTheme(loginPage, 'dark');
|
|
await loginPage.reload();
|
|
await expectNoHorizontalOverflow(loginPage);
|
|
await loginPage.screenshot({
|
|
path: testInfo.outputPath('login-mobile-dark.png'),
|
|
fullPage: true
|
|
});
|
|
await anonymousContext.close();
|
|
});
|
|
|
|
test('desktop fundus is a compact, filterable list with persistent themes', async ({
|
|
page
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
|
await page.goto('/fundus');
|
|
|
|
await expect(page.getByRole('heading', { name: 'Fundus', exact: true })).toBeVisible();
|
|
await expect(page.locator('.sidebar')).toBeVisible();
|
|
await expect(page.locator('.mobile-navigation')).toBeHidden();
|
|
await expect(page.locator('.utility-navigation a')).toHaveText('Verwaltung');
|
|
await expect(page.locator('.piece-card')).toHaveCount(4);
|
|
|
|
const firstRow = await page.locator('.piece-card').nth(0).boundingBox();
|
|
const secondRow = await page.locator('.piece-card').nth(1).boundingBox();
|
|
expect(firstRow?.x).toBe(secondRow?.x);
|
|
expect(firstRow?.y).toBeLessThan(secondRow?.y ?? 0);
|
|
expect(firstRow?.height ?? 999).toBeLessThan(130);
|
|
const searchBox = await page.locator('.search-field').boundingBox();
|
|
const filtersBox = await page.locator('.filter-tabs').boundingBox();
|
|
expect(searchBox?.height).toBe(filtersBox?.height);
|
|
await expect(page.getByRole('button', { name: 'In Arbeit' })).toHaveCSS('white-space', 'nowrap');
|
|
|
|
const notice = page.locator('.preview-notice');
|
|
await expect(page.getByText('Vorschau · Fundus', { exact: true })).toBeVisible();
|
|
await expect(notice.getByText('Diese Ansicht ist schreibgeschützt.')).toBeHidden();
|
|
await notice.locator('summary').focus();
|
|
await expect(notice.locator('summary')).toBeFocused();
|
|
await page.keyboard.press('Enter');
|
|
await expect(notice.getByText('Diese Ansicht ist schreibgeschützt.')).toBeVisible();
|
|
|
|
await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('Pixelart');
|
|
await expect(page.locator('.piece-card')).toHaveCount(1);
|
|
await page.getByRole('button', { name: 'In Arbeit' }).click();
|
|
await expect(page.getByRole('heading', { name: 'Kein passendes Stück' })).toBeVisible();
|
|
await page.getByRole('button', { name: 'Filter zurücksetzen' }).click();
|
|
await expect(page.locator('.piece-card')).toHaveCount(4);
|
|
|
|
await page.getByRole('button', { name: 'Darstellung: System' }).click();
|
|
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light');
|
|
await page.reload();
|
|
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light');
|
|
await expect(page.getByRole('button', { name: 'Darstellung: Hell' })).toBeVisible();
|
|
await expectNoHorizontalOverflow(page);
|
|
await page.screenshot({ path: testInfo.outputPath('fundus-desktop-light.png'), fullPage: true });
|
|
});
|
|
|
|
test('desktop builder keeps the fundus beside the wider run sheet', async ({ page }, testInfo) => {
|
|
await page.setViewportSize({ width: 1280, height: 900 });
|
|
await page.goto('/fundus');
|
|
await setTheme(page, 'dark');
|
|
await page.goto('/sendungen');
|
|
|
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
|
await expect(page.getByRole('heading', { name: 'Sendungen', exact: true })).toBeVisible();
|
|
await expect(page.getByText('Herbstsendung: Kleine Welten', { exact: true })).toBeVisible();
|
|
await expect(page.locator('.source-pane')).toBeVisible();
|
|
await expect(page.locator('.run-pane')).toBeVisible();
|
|
await expect(page.locator('.mobile-pane-switch')).toBeHidden();
|
|
await expect(
|
|
page.locator('.show-summary strong').filter({ hasText: '1 Std. 25 Min.' })
|
|
).toBeVisible();
|
|
|
|
const sourceBox = await page.locator('.source-pane').boundingBox();
|
|
const runBox = await page.locator('.run-pane').boundingBox();
|
|
expect(sourceBox?.x).toBeLessThan(runBox?.x ?? 0);
|
|
expect(sourceBox?.width ?? 9999).toBeLessThan(runBox?.width ?? 0);
|
|
await expectNoHorizontalOverflow(page);
|
|
await page.screenshot({
|
|
path: testInfo.outputPath('sendungen-desktop-dark.png'),
|
|
fullPage: true
|
|
});
|
|
});
|
|
|
|
test('narrow desktop retains the sidebar and switches the builder panes', async ({ page }) => {
|
|
await page.setViewportSize({ width: 960, height: 850 });
|
|
await page.goto('/sendungen');
|
|
|
|
await expect(page.locator('.sidebar')).toBeVisible();
|
|
await expect(page.locator('.mobile-navigation')).toBeHidden();
|
|
await expect(page.locator('.mobile-pane-switch')).toBeVisible();
|
|
await expect(page.locator('.run-pane')).toBeVisible();
|
|
await expect(page.locator('.source-pane')).toBeHidden();
|
|
await page.getByRole('button', { name: 'Fundus', exact: true }).click();
|
|
await expect(page.locator('.source-pane')).toBeVisible();
|
|
await expect(page.locator('.run-pane')).toBeHidden();
|
|
await expectNoHorizontalOverflow(page);
|
|
});
|
|
|
|
test('mobile and tablet navigation do not obscure switchable content', async ({
|
|
page
|
|
}, testInfo) => {
|
|
for (const width of [390, 768]) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
await page.goto('/sendungen');
|
|
|
|
await expect(page.locator('.sidebar')).toBeHidden();
|
|
await expect(page.locator('.mobile-navigation')).toBeVisible();
|
|
await expect(page.locator('.run-pane')).toBeVisible();
|
|
await page.getByRole('button', { name: 'Fundus', exact: true }).click();
|
|
await expect(page.locator('.source-pane')).toBeVisible();
|
|
await expect(page.locator('.run-pane')).toBeHidden();
|
|
await expectNoHorizontalOverflow(page);
|
|
|
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
const verticalSpace = await page.evaluate(() => {
|
|
const navigation = document.querySelector('.mobile-navigation')?.getBoundingClientRect();
|
|
const content = document.querySelector('.source-pane')?.getBoundingClientRect();
|
|
return { contentBottom: content?.bottom ?? 0, navigationTop: navigation?.top ?? 0 };
|
|
});
|
|
expect(verticalSpace.contentBottom).toBeLessThanOrEqual(verticalSpace.navigationTop + 1);
|
|
}
|
|
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.goto('/sendungen');
|
|
await page.screenshot({ path: testInfo.outputPath('sendungen-mobile.png'), fullPage: true });
|
|
});
|
|
|
|
test('all work areas fit at required widths in light and dark mode', async ({ page }, testInfo) => {
|
|
for (const viewport of viewports) {
|
|
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
await page.goto('/fundus');
|
|
await setTheme(page, viewport.theme);
|
|
|
|
for (const route of routes) {
|
|
await page.goto(route);
|
|
await expect(page.locator('html')).toHaveAttribute('data-theme', viewport.theme);
|
|
await expectNoHorizontalOverflow(page);
|
|
}
|
|
}
|
|
|
|
await page.setViewportSize({ width: 768, height: 900 });
|
|
await page.goto('/fundus');
|
|
await setTheme(page, 'dark');
|
|
await page.goto('/verwaltung');
|
|
await page.screenshot({
|
|
path: testInfo.outputPath('verwaltung-tablet-dark.png'),
|
|
fullPage: true
|
|
});
|
|
});
|
|
|
|
test('long titles and longer fundus lists reflow without overflow', async ({ page }, testInfo) => {
|
|
await page.setViewportSize({ width: 1280, height: 900 });
|
|
await page.goto('/fundus');
|
|
await expect(page.locator('.piece-card')).toHaveCount(4);
|
|
await page.evaluate(() => {
|
|
const list = document.querySelector('.piece-grid');
|
|
const rows = Array.from(document.querySelectorAll('.piece-card'));
|
|
const title = rows[0]?.querySelector('h2');
|
|
if (title) {
|
|
title.textContent =
|
|
'Ein außergewöhnlich langer redaktioneller Titel über kleine Konsolen, große Ideen und unerwartete Wendungen';
|
|
}
|
|
for (let repetition = 0; repetition < 3; repetition += 1) {
|
|
for (const row of rows) list?.append(row.cloneNode(true));
|
|
}
|
|
});
|
|
|
|
await expect(page.locator('.piece-card')).toHaveCount(16);
|
|
await expectNoHorizontalOverflow(page);
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await expectNoHorizontalOverflow(page);
|
|
await expect(page.locator('.piece-card').first()).toHaveCSS('flex-direction', 'column');
|
|
const mobileCard = await page.locator('.piece-card').first().boundingBox();
|
|
const mobilePrimary = await page.locator('.piece-primary').first().boundingBox();
|
|
expect(mobilePrimary?.width ?? 0).toBeGreaterThan((mobileCard?.width ?? 0) - 40);
|
|
await expect(page.locator('.piece-card').first().getByRole('heading')).toContainText(
|
|
'Ein außergewöhnlich langer redaktioneller Titel'
|
|
);
|
|
await page.screenshot({
|
|
path: testInfo.outputPath('fundus-mobile-long-content.png'),
|
|
fullPage: true
|
|
});
|
|
});
|
|
|
|
test('empty states and administration stay concise', async ({ page }) => {
|
|
for (const width of [390, 1280]) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
for (const route of ['/eingang', '/archiv']) {
|
|
await page.goto(route);
|
|
const emptyState = page.locator('.planned-panel');
|
|
await expect(emptyState).toBeVisible();
|
|
const box = await emptyState.boundingBox();
|
|
expect(box?.height ?? 999).toBeLessThan(150);
|
|
await expectNoHorizontalOverflow(page);
|
|
}
|
|
}
|
|
|
|
await page.setViewportSize({ width: 1280, height: 900 });
|
|
await page.goto('/verwaltung');
|
|
const panels = page.locator('.taxonomy-panel');
|
|
await expect(panels).toHaveCount(3);
|
|
const boxes = await Promise.all([
|
|
panels.nth(0).boundingBox(),
|
|
panels.nth(1).boundingBox(),
|
|
panels.nth(2).boundingBox()
|
|
]);
|
|
expect(boxes[0]?.y).toBe(boxes[1]?.y);
|
|
expect(boxes[1]?.y).toBe(boxes[2]?.y);
|
|
});
|
|
|
|
test('live preview retains its existing readable controls', async ({ page }) => {
|
|
await page.setViewportSize({ width: 1280, height: 900 });
|
|
await page.goto('/live');
|
|
|
|
await expect(page.getByText('Vorschau · Live-Modus', { exact: true })).toBeVisible();
|
|
await expect(page.getByText('Eine Kurbel, ein gelber Knopf und sehr viel Charme')).toBeVisible();
|
|
const before = await page
|
|
.locator('.live-current')
|
|
.evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size'));
|
|
await page.getByRole('button', { name: 'Schrift vergrößern' }).click();
|
|
const after = await page
|
|
.locator('.live-current')
|
|
.evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size'));
|
|
expect(after).not.toBe(before);
|
|
await page.getByText('Recherche öffnen').click();
|
|
await expect(page.locator('.research-details')).toHaveAttribute('open', '');
|
|
await expectNoHorizontalOverflow(page);
|
|
});
|