309 lines
16 KiB
TypeScript
309 lines
16 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
||
import { mkdir } from 'node:fs/promises';
|
||
import { pasta } from '../fixtures';
|
||
import { exportRecipes } from '../../src/lib/import';
|
||
const id = '10000000-0000-4000-8000-000000000001';
|
||
test('external images and structured steps survive import, editing, scaling and cooking', async ({
|
||
page,
|
||
}, info) => {
|
||
const headers = { origin: 'http://localhost:3100' };
|
||
const errors: string[] = [];
|
||
page.on('pageerror', (e) => errors.push(e.message));
|
||
await page.route('https://images.example.org/**', async (route) => {
|
||
if (route.request().url().includes('missing')) await route.fulfill({ status: 404, body: '' });
|
||
else await route.fulfill({ contentType: 'image/jpeg', path: 'public/images/garden.jpg' });
|
||
});
|
||
const cover = 'https://images.example.org/cover.jpg?width=1200&quality=80';
|
||
const stepImage = 'https://images.example.org/step.jpg';
|
||
const recipe = {
|
||
...pasta,
|
||
id: undefined,
|
||
title: `Bilderküche ${info.project.name}`,
|
||
image: cover,
|
||
images: ['https://images.example.org/gallery.jpg'],
|
||
tips: 'Bildquelle: https://commons.wikimedia.org/wiki/File:Pljeskavica_cheese_chili_with_a_very_long_unbroken_filename.jpg\n\nLizenz: https://creativecommons.org/licenses/by-sa/4.0/',
|
||
steps: pasta.steps.map((s, n) =>
|
||
n === 0
|
||
? {
|
||
...s,
|
||
text: 'Alles für das Pesto vorbereiten.\n\n- {{basilikum}} waschen und trocken schütteln.\n- {{kaese}} fein reiben.\n\n1. Einen großen Topf Wasser aufsetzen.\n2. Die Zutaten bereitstellen.',
|
||
images: [stepImage, 'https://images.example.org/missing.jpg'],
|
||
}
|
||
: s,
|
||
),
|
||
};
|
||
expect(
|
||
(
|
||
await page.request.post('/api/auth/login', {
|
||
headers,
|
||
data: { username: 'testadmin', password: process.env.TEST_PASSWORD },
|
||
})
|
||
).ok(),
|
||
).toBe(true);
|
||
const imported = await page.request.post('/api/import', {
|
||
headers,
|
||
data: { format: 'json', preview: false, raw: exportRecipes([recipe], 'json') },
|
||
});
|
||
expect(imported.status()).toBe(201);
|
||
const [saved] = await imported.json();
|
||
try {
|
||
await page.goto(`/admin/rezepte/${saved.id}`);
|
||
await expect(page.getByLabel('Titelbild-Adresse (HTTPS oder lokaler Bildpfad)')).toHaveValue(
|
||
cover,
|
||
);
|
||
await page.getByRole('button', { name: 'Zubereitung', exact: true }).click();
|
||
await page.getByText('Bilder zu Schritt 1 (2)', { exact: true }).click();
|
||
await page
|
||
.getByLabel('Schritt 1: Bild: Bildadresse', { exact: true })
|
||
.fill('https://images.example.org/another.jpg');
|
||
await page.getByRole('button', { name: 'Schritt 1: Bild verlinken', exact: true }).click();
|
||
await page.getByRole('button', { name: 'Rezept speichern', exact: true }).click();
|
||
await expect(page.getByRole('status')).toContainText('erfolgreich gespeichert');
|
||
const exported = await (await page.request.get(`/api/export?id=${saved.id}`)).json();
|
||
expect(exported.recipes[0].steps[0].images).toEqual([
|
||
...recipe.steps[0].images,
|
||
'https://images.example.org/another.jpg',
|
||
]);
|
||
const response = await page.goto(`/rezepte/${saved.id}`);
|
||
expect(response?.headers()['content-security-policy']).toContain("img-src 'self' https:");
|
||
const first = page.locator('.preparation-step').first();
|
||
await expect(first.locator('.step-instructions > p')).toHaveCount(1);
|
||
await expect(first.locator('.step-instructions > ul > li')).toHaveCount(2);
|
||
await expect(first.locator('.step-instructions > ol > li')).toHaveCount(2);
|
||
await first.locator('.step-images').scrollIntoViewIfNeeded();
|
||
await expect(first.getByRole('img', { name: /Bild 2 – Bild nicht verfügbar/ })).toBeVisible();
|
||
await expect
|
||
.poll(() =>
|
||
page.locator('.recipe-cover img').evaluate((img: HTMLImageElement) => img.naturalWidth),
|
||
)
|
||
.toBeGreaterThan(0);
|
||
await expect(first.locator('.step-images img').first()).toHaveJSProperty('complete', true);
|
||
expect(
|
||
await first
|
||
.locator('.step-images img')
|
||
.first()
|
||
.evaluate((img: HTMLImageElement) => img.naturalWidth),
|
||
).toBeGreaterThan(0);
|
||
await page.getByRole('spinbutton', { name: 'Portionen', exact: true }).fill('4');
|
||
await expect(first.locator('.instruction-ingredient').first()).toHaveText('80 g Basilikum');
|
||
await expect(first.locator('.step-supplies')).toContainText('80 g Basilikum');
|
||
expect(
|
||
await page
|
||
.locator('.recipe-columns')
|
||
.evaluate((el) =>
|
||
[...el.querySelectorAll('*')].every(
|
||
(child) =>
|
||
child.getBoundingClientRect().right <= document.documentElement.clientWidth + 1,
|
||
),
|
||
),
|
||
).toBe(true);
|
||
expect(
|
||
await page.evaluate(
|
||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||
),
|
||
).toBe(true);
|
||
await page.screenshot({ path: info.outputPath('structured-steps.png'), fullPage: true });
|
||
await page.getByRole('button', { name: 'Theme wechseln', exact: true }).click();
|
||
await first.screenshot({ path: info.outputPath('structured-step-dark.png') });
|
||
await page.goto(`/rezepte/${saved.id}/kochen?faktor=2`);
|
||
await expect(page.locator('.cooking-instruction .instruction-ingredient').first()).toHaveText(
|
||
'80 g Basilikum',
|
||
);
|
||
await expect(page.locator('.cooking-step .step-images img')).toHaveCount(2);
|
||
await expect(page.locator('.cooking-step')).toHaveCSS('opacity', '1');
|
||
expect(
|
||
await page.evaluate(
|
||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||
),
|
||
).toBe(true);
|
||
await page.screenshot({ path: info.outputPath('structured-cooking.png'), fullPage: true });
|
||
expect(errors).toEqual([]);
|
||
} finally {
|
||
await page.request.delete(`/api/recipes/${saved.id}`, { headers });
|
||
}
|
||
});
|
||
test('editor remains usable on the current viewport in dark mode', async ({ page }, info) => {
|
||
await page.goto('/admin');
|
||
await page.getByLabel('Benutzername').fill('testadmin');
|
||
await page.getByLabel('Passwort', { exact: true }).fill(process.env.TEST_PASSWORD!);
|
||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
|
||
await page.getByRole('link', { name: 'Neues Rezept' }).click();
|
||
await page.getByRole('button', { name: 'Theme wechseln', exact: true }).click();
|
||
await page.getByRole('button', { name: 'Zutaten', exact: true }).click();
|
||
await page.getByRole('textbox', { name: 'Zutat 1', exact: true }).fill('Kartoffeln');
|
||
await page.getByRole('spinbutton', { name: 'Menge 1', exact: true }).fill('400');
|
||
await page.getByRole('button', { name: 'Details für Zutat 1', exact: true }).click();
|
||
await page.getByRole('combobox', { name: 'Abschnitt 1', exact: true }).fill('Für das Gemüse');
|
||
await page
|
||
.getByRole('textbox', { name: 'Hinweis 1', exact: true })
|
||
.fill('geschält und gewürfelt');
|
||
await page.getByRole('button', { name: 'Details für Zutat 1', exact: true }).click();
|
||
await page.getByRole('button', { name: 'Zutat hinzufügen' }).click();
|
||
await expect(page.getByRole('textbox', { name: 'Zutat 2', exact: true })).toBeFocused();
|
||
await page.getByRole('textbox', { name: 'Zutat 2', exact: true }).fill('Karotten');
|
||
await page.getByRole('spinbutton', { name: 'Menge 2', exact: true }).fill('200');
|
||
await page.getByRole('button', { name: 'Details für Zutat 2', exact: true }).click();
|
||
await page.getByRole('checkbox', { name: 'Optionale Zutat' }).check();
|
||
await page.getByRole('button', { name: 'Zutat 2 nach oben' }).click();
|
||
await expect(page.getByRole('textbox', { name: 'Zutat 1', exact: true })).toHaveValue('Karotten');
|
||
await page.getByRole('button', { name: 'Details für Zutat 1', exact: true }).click();
|
||
await page.getByRole('textbox', { name: 'Zutat 2', exact: true }).press('Enter');
|
||
await expect(page.getByRole('textbox', { name: 'Zutat 3', exact: true })).toBeFocused();
|
||
await page.getByRole('textbox', { name: 'Zutat 3', exact: true }).fill('Zwiebel');
|
||
await page.getByRole('spinbutton', { name: 'Menge 3', exact: true }).fill('1');
|
||
await page.getByRole('combobox', { name: 'Einheit 3', exact: true }).selectOption('Stück');
|
||
await page.getByRole('textbox', { name: 'Zutat 3', exact: true }).press('Enter');
|
||
await page.getByRole('textbox', { name: 'Zutat 4', exact: true }).fill('Olivenöl');
|
||
await page.getByRole('spinbutton', { name: 'Menge 4', exact: true }).fill('2');
|
||
await page.getByRole('combobox', { name: 'Einheit 4', exact: true }).selectOption('EL');
|
||
page.on('dialog', (dialog) => dialog.accept());
|
||
await page.reload();
|
||
await page.getByRole('button', { name: 'Zutaten', exact: true }).click();
|
||
await expect(page.getByRole('textbox', { name: 'Zutat 1', exact: true })).toHaveValue('Karotten');
|
||
await expect(page.locator('.ingredient-entry').nth(0)).toContainText('optional');
|
||
await expect(page.locator('.ingredient-entry').nth(1)).toContainText('geschält und gewürfelt');
|
||
expect(
|
||
await page.evaluate(
|
||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||
),
|
||
).toBe(true);
|
||
await page.screenshot({
|
||
path: `docs/screenshots/editor-dark-${info.project.name}.png`,
|
||
fullPage: true,
|
||
animations: 'disabled',
|
||
});
|
||
await page.getByRole('button', { name: 'Theme wechseln', exact: true }).click();
|
||
await page.screenshot({
|
||
path: `docs/screenshots/ingredients-light-${info.project.name}.png`,
|
||
fullPage: true,
|
||
animations: 'disabled',
|
||
});
|
||
if (info.project.name === 'desktop') {
|
||
await page.locator('.ingredient-grip').first().dragTo(page.locator('.ingredient-entry').nth(1));
|
||
await expect(page.getByRole('textbox', { name: 'Zutat 1', exact: true })).toHaveValue(
|
||
'Kartoffeln',
|
||
);
|
||
}
|
||
await page.getByRole('button', { name: 'Live-Vorschau', exact: true }).click();
|
||
expect(
|
||
await page.evaluate(
|
||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||
),
|
||
).toBe(true);
|
||
});
|
||
test('public library, shareable search, favorites, themes and responsive layouts', async ({
|
||
page,
|
||
}, info) => {
|
||
const errors: string[] = [];
|
||
page.on('pageerror', (e) => errors.push(e.message));
|
||
await page.goto('/');
|
||
await expect(page.getByRole('heading', { name: 'Zuhause schmeckt’s am besten.' })).toBeVisible();
|
||
await expect(page.locator('.recipe-card')).toHaveCount(3);
|
||
await expect(page.getByText('Geheimer Entwurf')).toHaveCount(0);
|
||
await page.evaluate(() => document.fonts.ready);
|
||
for (const card of await page.locator('.recipe-card').all())
|
||
await expect(card).toHaveCSS('opacity', '1');
|
||
await mkdir('docs/screenshots', { recursive: true });
|
||
await page.screenshot({
|
||
path: `docs/screenshots/library-${info.project.name}.png`,
|
||
fullPage: true,
|
||
animations: 'disabled',
|
||
});
|
||
expect(
|
||
await page.evaluate(
|
||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||
),
|
||
).toBe(true);
|
||
await page.getByRole('textbox', { name: 'Rezepte suchen' }).fill('Basilikum');
|
||
await expect(page.locator('.recipe-card')).toHaveCount(1);
|
||
await expect(page).toHaveURL(/q=Basilikum/);
|
||
await page.getByRole('button', { name: 'Suche löschen' }).click();
|
||
await page.getByRole('button', { name: 'Grüne Pesto-Pasta als Favorit merken' }).click();
|
||
const nav =
|
||
page.viewportSize()!.width <= 700
|
||
? page.getByRole('navigation', { name: 'Mobile Navigation' })
|
||
: page.getByRole('navigation', { name: 'Hauptnavigation' });
|
||
await nav.getByRole('link', { name: /Favoriten|Lieblingsrezepte/ }).click();
|
||
await expect(page.locator('.recipe-card')).toHaveCount(1);
|
||
await page.getByRole('button', { name: 'Theme wechseln', exact: true }).click();
|
||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||
await expect(page.locator('.recipe-card')).toHaveCSS('opacity', '1');
|
||
await page.screenshot({
|
||
path: `docs/screenshots/favorites-dark-${info.project.name}.png`,
|
||
fullPage: true,
|
||
animations: 'disabled',
|
||
});
|
||
await page.reload();
|
||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||
expect(errors).toEqual([]);
|
||
});
|
||
test('portion calculator, ingredient anchor, shopping list and print content', async ({
|
||
page,
|
||
}, info) => {
|
||
await page.goto(`/rezepte/${id}`);
|
||
await page.getByRole('spinbutton', { name: 'Portionen', exact: true }).fill('8');
|
||
await page.getByRole('spinbutton', { name: 'Portionen', exact: true }).press('Tab');
|
||
await expect(page.getByRole('button', { name: 'Menge Pasta ändern' })).toHaveText('800 g');
|
||
await expect(page.locator('.steps-list')).toContainText('800 g Pasta');
|
||
await page.getByRole('button', { name: 'Nach Zutat', exact: true }).click();
|
||
await page.getByRole('button', { name: 'Menge Pasta ändern' }).click();
|
||
await page.getByRole('textbox', { name: 'Neue Menge Pasta' }).fill('0,4');
|
||
await page.getByRole('combobox', { name: 'Einheit Pasta' }).selectOption('kg');
|
||
await page.getByRole('button', { name: 'Menge übernehmen' }).click();
|
||
await expect(page.getByRole('button', { name: 'Menge Pasta ändern' })).toHaveText('400 g');
|
||
await expect(page.locator('.panel-title')).toContainText('FÜR 4 PORTIONEN');
|
||
await expect(page.getByRole('button', { name: 'Menge Olivenöl ändern' })).toHaveText('120 ml');
|
||
await page.screenshot({
|
||
path: `docs/screenshots/recipe-${info.project.name}.png`,
|
||
fullPage: true,
|
||
});
|
||
expect(
|
||
await page.evaluate(
|
||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||
),
|
||
).toBe(true);
|
||
await page.getByRole('button', { name: 'Zur Einkaufsliste hinzufügen' }).click();
|
||
await page.goto('/einkaufsliste');
|
||
await expect(page.locator('.shopping-list')).toContainText('400 g');
|
||
await page.locator('.shopping-item').first().click();
|
||
await page.reload();
|
||
await expect(page.locator('.shopping-list li.is-checked')).toHaveCount(1);
|
||
await page.goto(`/rezepte/${id}`);
|
||
await page.emulateMedia({ media: 'print' });
|
||
await expect(page.locator('.site-header')).toBeHidden();
|
||
await expect(page.locator('.ingredients-panel')).toBeVisible();
|
||
});
|
||
test('guided cooking restores scaled progress and timestamp timers through navigation', async ({
|
||
page,
|
||
}, info) => {
|
||
await page.goto(`/rezepte/${id}/kochen?faktor=2`);
|
||
await expect(page.locator('.cooking-instruction')).toContainText('80 g Basilikum');
|
||
await page.locator('.step-ingredients button').first().click();
|
||
await page.getByRole('button', { name: 'Nächster Schritt' }).click();
|
||
await expect(page.getByRole('heading', { name: 'Das Pesto zum Leuchten bringen' })).toBeVisible();
|
||
await page.getByRole('button', { name: 'Pinienkerne rösten starten' }).click();
|
||
await expect(page.getByRole('button', { name: 'Pinienkerne rösten pausieren' })).toBeVisible();
|
||
await expect(page.locator('.cooking-step')).toHaveCSS('opacity', '1');
|
||
await page.screenshot({
|
||
path: `docs/screenshots/cooking-${info.project.name}.png`,
|
||
fullPage: true,
|
||
});
|
||
await page.getByRole('button', { name: 'Nächster Schritt' }).click();
|
||
await expect(page.getByRole('button', { name: 'Pinienkerne rösten pausieren' })).toBeVisible();
|
||
await page.getByRole('button', { name: 'Zurück', exact: true }).click();
|
||
await page.reload();
|
||
await expect(page.getByRole('heading', { name: 'Das Pesto zum Leuchten bringen' })).toBeVisible();
|
||
await expect(page.getByRole('button', { name: 'Pinienkerne rösten pausieren' })).toBeVisible();
|
||
await page.getByRole('button', { name: 'Pinienkerne rösten pausieren' }).click();
|
||
await page.getByRole('button', { name: 'Pinienkerne rösten zurücksetzen' }).click();
|
||
await expect(page.getByRole('timer')).toHaveText('03:00');
|
||
await page.getByRole('button', { name: 'Nächster Schritt' }).click();
|
||
await page.getByRole('button', { name: 'Nächster Schritt' }).click();
|
||
await page.getByRole('button', { name: 'Fertig gekocht' }).click();
|
||
await expect(page.getByRole('heading', { name: 'Guten Appetit!' })).toBeVisible();
|
||
await page.reload();
|
||
await expect(page.getByRole('heading', { name: 'Guten Appetit!' })).toBeVisible();
|
||
await page.getByRole('button', { name: 'Erneut kochen' }).click();
|
||
await expect(page.getByRole('heading', { name: 'Ein bisschen Vorbereitung' })).toBeVisible();
|
||
});
|