Spaces:
Sleeping
Sleeping
| import { expect, test } from '@playwright/test'; | |
| import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises'; | |
| import path from 'node:path'; | |
| const demoMode = process.env.E2E_DEMO_MODE === '1'; | |
| const demoDelayMs = 2000; | |
| const demoImagePaths = [ | |
| path.join(process.cwd(), 'e2e', 'fixtures', 'menu-burger.jpg'), | |
| path.join(process.cwd(), 'e2e', 'fixtures', 'menu-drink.jpg'), | |
| path.join(process.cwd(), 'e2e', 'fixtures', 'menu-fries.jpg'), | |
| ]; | |
| const demoOutputDirectory = path.join(process.cwd(), 'test-results', 'e2e', 'kaggle-demo'); | |
| const demoRunId = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14); | |
| const prompt = 'Maak een professionele fastfood menu deal met burger, drinken en friet op een schone witte menu-achtergrond'; | |
| type GenerationMetrics = { | |
| durationSeconds?: number; | |
| inferenceSeconds?: number; | |
| modelLoadSeconds?: number; | |
| setupSeconds?: number; | |
| steps?: number; | |
| gpuName?: string; | |
| }; | |
| async function waitForDemo(): Promise<void> { | |
| if (!demoMode) { | |
| return; | |
| } | |
| await new Promise((resolvePromise) => setTimeout(resolvePromise, demoDelayMs)); | |
| } | |
| test('generates a real menu deal image through Kaggle', async ({ page }) => { | |
| test.setTimeout(7_500_000); | |
| await page.goto('/'); | |
| await waitForDemo(); | |
| await expect(page.getByRole('heading', { name: 'Maak een menu-afbeelding.' })).toBeVisible(); | |
| await expect(page.getByRole('button', { name: 'Maak nieuwe beelden' })).toBeDisabled(); | |
| await page.locator('input[type="file"]:not([webkitdirectory])').setInputFiles(demoImagePaths); | |
| await waitForDemo(); | |
| await expect(page.getByText('menu-burger.jpg')).toBeVisible(); | |
| await expect(page.getByText('menu-drink.jpg')).toBeVisible(); | |
| await expect(page.getByText('menu-fries.jpg')).toBeVisible(); | |
| await page.getByLabel('Type beeld').selectOption('menu'); | |
| await page.getByLabel('Stijl').selectOption('studio'); | |
| await page.getByLabel('Formaat').selectOption('1:1'); | |
| await page.getByLabel('Instructie').fill(prompt); | |
| await waitForDemo(); | |
| const generationResponsePromise = page.waitForResponse('http://127.0.0.1:3000/api/generations', { timeout: 7_200_000 }); | |
| await page.getByRole('button', { name: 'Maak nieuwe beelden' }).click(); | |
| await expect(page.getByRole('button', { name: 'Beelden maken...' })).toBeVisible(); | |
| await waitForDemo(); | |
| const generationResponse = await generationResponsePromise; | |
| const generationBody = await generationResponse.text(); | |
| if (generationResponse.status() !== 202) { | |
| await writeFailureReport(generationResponse.status(), generationBody); | |
| } | |
| expect(generationResponse.status(), generationBody).toBe(202); | |
| const jobId = parseGenerationJobId(generationBody); | |
| const completedGenerationResponse = await page.waitForResponse(async (response) => { | |
| if (response.url() !== `http://127.0.0.1:3000/api/generations/${jobId}` || response.status() !== 200) { | |
| return false; | |
| } | |
| const body = await response.text(); | |
| return parseGenerationJobStatus(body) === 'completed'; | |
| }, { timeout: 7_200_000 }); | |
| const completedGenerationBody = await completedGenerationResponse.text(); | |
| const generationMetrics = parseGenerationMetrics(completedGenerationBody); | |
| await expect(page.getByRole('button', { name: 'Maak nieuwe beelden' })).toBeVisible(); | |
| await expect(page.getByText(/beeld is klaar voor controle\./).first()).toBeVisible({ timeout: 30_000 }); | |
| await waitForDemo(); | |
| await page.locator('.image-card').first().getByRole('button', { name: 'Clean studio' }).click(); | |
| await expect(page.locator('.image-card').first().getByText(/beeld is klaar voor controle\./)).toBeVisible(); | |
| await waitForDemo(); | |
| const generatedSource = await page.locator('.image-card').first().locator('.generated img').getAttribute('src'); | |
| expect(generatedSource?.startsWith('data:image/')).toBe(true); | |
| if (generatedSource !== null) { | |
| await writeDemoReport(generatedSource, generationMetrics); | |
| } | |
| await page.locator('.image-card').first().getByRole('button', { name: 'Verwijder' }).click(); | |
| await waitForDemo(); | |
| await expect(page.getByText('menu-burger.jpg')).not.toBeVisible(); | |
| }); | |
| async function writeDemoReport(generatedSource: string, metrics: GenerationMetrics | undefined): Promise<void> { | |
| const originalFileNames = ['input-menu-burger.jpg', 'input-menu-drink.jpg', 'input-menu-fries.jpg']; | |
| const generatedFileName = `generated-${demoRunId}.png`; | |
| const reportFileName = 'result.md'; | |
| await resetDemoOutputDirectory(); | |
| await mkdir(demoOutputDirectory, { recursive: true }); | |
| for (const [index, imagePath] of demoImagePaths.entries()) { | |
| await copyFile(imagePath, path.join(demoOutputDirectory, originalFileNames[index])); | |
| } | |
| const generatedImage = decodeDataUrl(generatedSource); | |
| await writeFile(path.join(demoOutputDirectory, generatedFileName), generatedImage); | |
| await writeFile(path.join(demoOutputDirectory, 'generated.png'), generatedImage); | |
| await writeFile( | |
| path.join(demoOutputDirectory, reportFileName), | |
| [ | |
| '# Kaggle FLUX.2 klein Production Menu E2E Result', | |
| '', | |
| '## Prompt', | |
| '', | |
| prompt, | |
| '', | |
| '## Input Images', | |
| '', | |
| ...originalFileNames.map((fileName) => ``), | |
| '', | |
| '## Timing', | |
| '', | |
| ...buildTimingLines(metrics), | |
| '', | |
| '## Visual Acceptance Criteria', | |
| '', | |
| '- One composed menu-deal image, not a copied collage.', | |
| '- White menu-board/product photography background.', | |
| '- Burger, exactly one drink, and exactly one side item must be visible and correctly scaled.', | |
| '- No duplicate drinks, duplicate sides, or extra products.', | |
| '- No added brand logos, prices, labels, or promotional text.', | |
| '', | |
| '## Generated Image', | |
| '', | |
| ``, | |
| '', | |
| ].join('\n'), | |
| ); | |
| } | |
| function buildTimingLines(metrics: GenerationMetrics | undefined): string[] { | |
| if (metrics === undefined) { | |
| return ['- Model inference: not reported by provider.']; | |
| } | |
| return [ | |
| `- Model inference only: ${formatSeconds(metrics.inferenceSeconds)}`, | |
| `- Model load: ${formatSeconds(metrics.modelLoadSeconds)}`, | |
| `- Setup before model load: ${formatSeconds(metrics.setupSeconds)}`, | |
| `- Total Kaggle kernel runtime: ${formatSeconds(metrics.durationSeconds)}`, | |
| `- Steps: ${metrics.steps ?? 'unknown'}`, | |
| `- GPU: ${metrics.gpuName ?? 'unknown'}`, | |
| ]; | |
| } | |
| function formatSeconds(seconds: number | undefined): string { | |
| if (seconds === undefined) { | |
| return 'unknown'; | |
| } | |
| return `${seconds.toFixed(2)}s`; | |
| } | |
| function parseGenerationMetrics(responseBody: string): GenerationMetrics | undefined { | |
| const response = JSON.parse(responseBody); | |
| if (!hasMetrics(response)) { | |
| return undefined; | |
| } | |
| return response.result?.metrics ?? response.metrics; | |
| } | |
| function parseGenerationJobId(responseBody: string): string { | |
| const response = JSON.parse(responseBody); | |
| if (!hasJobId(response)) { | |
| throw new Error('Generation response did not include a job id'); | |
| } | |
| return response.jobId; | |
| } | |
| function parseGenerationJobStatus(responseBody: string): string | undefined { | |
| const response = JSON.parse(responseBody); | |
| if (typeof response !== 'object' || response === null || !('status' in response)) { | |
| return undefined; | |
| } | |
| const status = (<{ status?: unknown }>response).status; | |
| return typeof status === 'string' ? status : undefined; | |
| } | |
| function hasMetrics(value: unknown): value is { metrics: GenerationMetrics } { | |
| if (typeof value !== 'object' || value === null) { | |
| return false; | |
| } | |
| const response = <{ metrics?: unknown; result?: { metrics?: unknown } }>value; | |
| const metrics = response.result?.metrics ?? response.metrics; | |
| return typeof metrics === 'object' && metrics !== null; | |
| } | |
| function hasJobId(value: unknown): value is { jobId: string } { | |
| if (typeof value !== 'object' || value === null || !('jobId' in value)) { | |
| return false; | |
| } | |
| return typeof (<{ jobId?: unknown }>value).jobId === 'string'; | |
| } | |
| async function writeFailureReport(status: number, responseBody: string): Promise<void> { | |
| const originalFileNames = ['input-menu-burger.jpg', 'input-menu-drink.jpg', 'input-menu-fries.jpg']; | |
| const reportFileName = 'result.md'; | |
| await resetDemoOutputDirectory(); | |
| await mkdir(demoOutputDirectory, { recursive: true }); | |
| for (const [index, imagePath] of demoImagePaths.entries()) { | |
| await copyFile(imagePath, path.join(demoOutputDirectory, originalFileNames[index])); | |
| } | |
| await writeFile( | |
| path.join(demoOutputDirectory, reportFileName), | |
| [ | |
| '# Kaggle FLUX.2 klein Production Menu E2E Result', | |
| '', | |
| '## Status', | |
| '', | |
| `Generation failed with HTTP ${status}.`, | |
| '', | |
| '## Prompt', | |
| '', | |
| prompt, | |
| '', | |
| '## Input Images', | |
| '', | |
| ...originalFileNames.map((fileName) => ``), | |
| '', | |
| '## Error', | |
| '', | |
| '```json', | |
| responseBody, | |
| '```', | |
| '', | |
| ].join('\n'), | |
| ); | |
| } | |
| async function resetDemoOutputDirectory(): Promise<void> { | |
| await rm(demoOutputDirectory, { force: true, recursive: true }); | |
| } | |
| function decodeDataUrl(dataUrl: string): Buffer { | |
| const base64Marker = ';base64,'; | |
| const markerIndex = dataUrl.indexOf(base64Marker); | |
| if (markerIndex === -1) { | |
| throw new Error('Generated image was not a base64 data URL'); | |
| } | |
| return Buffer.from(dataUrl.slice(markerIndex + base64Marker.length), 'base64'); | |
| } | |