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'], 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.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).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 <= innerWidth)).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 <= innerWidth)).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 <= innerWidth)).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 <= innerWidth)).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 <= innerWidth)).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(); });