feat: support external and step images with structured cooking instructions

This commit is contained in:
Sascha Nesterovic committed 2026-10-24 17:31:47 +02:00
1 parent 361862ad0b
commit 0ec1d45bdb
34 files changed
+898 -92

No files matched your search

+21
View File
@@ -36,6 +36,9 @@ test('admin creates and edits recipes with recoverable drafts and image upload',
await page.getByRole('textbox', { name: 'Anleitung 1' }).fill('In Salzwasser weich kochen.');
await page.getByRole('button', { name: 'Kartoffeln', exact: true }).click();
await page.getByRole('button', { name: 'Timer hinzufügen' }).click();
await page.getByText('Bilder zu Schritt 1 (0)', { exact: true }).click();
await page.getByLabel('Schritt 1: Bild hochladen').setInputFiles('public/images/garden.jpg');
await expect(page.getByRole('img', { name: 'Schritt 1: Bild 1', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Live-Vorschau', exact: true }).click();
await expect(page.locator('.editor-preview')).toContainText('400 g Kartoffeln');
await page.screenshot({ path: 'docs/screenshots/editor-desktop.png', fullPage: true });
@@ -58,11 +61,29 @@ test('admin creates and edits recipes with recoverable drafts and image upload',
const download = await downloadPromise;
const data = JSON.parse(await readFile((await download.path())!, 'utf8'));
expect(data.recipes[0].title).toBe('Browser-Küchenrezept verbessert');
const stepImage = data.recipes[0].steps[0].images[0];
expect(stepImage).toMatch(/^\/media\/.+\.webp$/);
expect(
(
await page.request.delete('/api/media', {
headers: { origin: 'http://localhost:3100' },
data: { name: stepImage.split('/').at(-1) },
})
).status(),
).toBe(409);
await page.getByRole('button', { name: 'Browser-Küchenrezept verbessert löschen' }).click();
await page.getByRole('button', { name: 'Endgültig löschen' }).click();
await expect(page.getByRole('heading', { name: 'Browser-Küchenrezept verbessert' })).toHaveCount(
0,
);
expect(
(
await page.request.delete('/api/media', {
headers: { origin: 'http://localhost:3100' },
data: { name: stepImage.split('/').at(-1) },
})
).status(),
).toBe(200);
await page.getByRole('button', { name: 'Abmelden' }).click();
await expect(page.getByRole('heading', { name: 'Willkommen zurück.' })).toBeVisible();
});
+107
View File
@@ -1,6 +1,112 @@
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');
@@ -146,6 +252,7 @@ test('guided cooking restores scaled progress and timestamp timers through navig
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,