Files
HamHam/tests/e2e/public.spec.ts
T

278 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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();
});