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,
+47
View File
@@ -17,6 +17,53 @@ describe('Versioned, safe, lossless recipe interchange', () => {
};
expect(parseImport(exportRecipes([r], 'xml'), 'xml').recipes[0]).toEqual(r);
});
it.each(['json', 'xml'] as const)(
'roundtrips external cover, gallery and step images in %s',
(format) => {
const r = recipeSchema.parse({
...pasta,
image: 'https://images.example.org/cover?width=1200&format=webp',
images: ['https://images.example.org/gallery.jpg'],
steps: pasta.steps.map((s, n) => ({
...s,
images:
n === 0 ? ['/images/kitchen.jpg', 'https://images.example.org/step.png?x=1&y=2'] : [],
})),
});
expect(parseImport(exportRecipes([r], format), format).recipes[0]).toEqual(r);
},
);
it('keeps older recipes without step images compatible', () => {
const r = { ...pasta, steps: pasta.steps.map(({ images, ...step }) => step) };
expect(
parseImport(JSON.stringify({ version: 1, recipes: [r] }), 'json').recipes[0].steps.every(
(s) => s.images.length === 0,
),
).toBe(true);
});
it.each([
'http://example.org/a.jpg',
'//example.org/a.jpg',
'javascript:alert(1)',
'data:image/png;base64,AAAA',
'https://user:password@example.org/a.jpg',
'https://',
'https://example.org/\\evil',
'https://example.org/' + 'a'.repeat(2048),
])('rejects unsupported image URL %s', (image) => {
expect(recipeSchema.safeParse({ ...pasta, image }).success).toBe(false);
expect(
recipeSchema.safeParse({ ...pasta, steps: [{ ...pasta.steps[0], images: [image] }] }).success,
).toBe(false);
});
it('limits step images', () => {
expect(
recipeSchema.safeParse({
...pasta,
steps: [{ ...pasta.steps[0], images: Array(11).fill('/images/kitchen.jpg') }],
}).success,
).toBe(false);
});
it('rejects unknown versions and missing mandatory fields', () => {
expect(() => parseImport('{"version":2,"recipes":[]}', 'json')).toThrow();
expect(() => parseImport('{"version":1,"recipes":[{"title":"Test"}]}', 'json')).toThrow();
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { instructionBlocks } from '../../src/lib/instructions';
describe('Readable plain-text instructions', () => {
it('preserves ingredient references, paragraphs and both list forms', () => {
expect(
instructionBlocks(
'Vorbereiten.\r\n\r\n- {{fleisch}} schneiden.\r\n- Salzen.\n\nAnschließend:\n1. Anbraten.\n2) Servieren.',
),
).toEqual([
{ type: 'paragraph', lines: ['Vorbereiten.'] },
{ type: 'unordered', lines: ['{{fleisch}} schneiden.', 'Salzen.'] },
{ type: 'paragraph', lines: ['Anschließend:'] },
{ type: 'ordered', lines: ['Anbraten.', 'Servieren.'] },
]);
});
it('keeps decimal quantities and temperatures intact when splitting legacy text', () => {
const text =
'Das Fleisch in 1.5 cm große Würfel schneiden und anschließend gründlich trocken tupfen. Die Pfanne auf mittlerer Hitze vorheizen und das Öl darin gleichmäßig verteilen. Das Fleisch vorsichtig hineinlegen und unter gelegentlichem Wenden goldbraun braten. Bei 180 °C im vorgeheizten Ofen fertig garen und anschließend servieren.';
const blocks = instructionBlocks(text);
expect(blocks.length).toBeGreaterThan(1);
expect(blocks.flatMap((b) => b.lines).join(' ')).toBe(text);
});
it('leaves short prose, explicit line breaks and literal HTML as text', () => {
expect(instructionBlocks('Bei 180 °C garen.')).toEqual([
{ type: 'paragraph', lines: ['Bei 180 °C garen.'] },
]);
expect(instructionBlocks('<script>test</script>\nFertig.')).toEqual([
{ type: 'paragraph', lines: ['<script>test</script>'] },
{ type: 'paragraph', lines: ['Fertig.'] },
]);
});
});