mplus-studio / e2e /studio-flow.spec.ts
joelsefanja's picture
Deploy Mplus Studio
5fb96a0 verified
Raw
History Blame Contribute Delete
9.45 kB
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) => `![Input](./${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',
'',
`![Generated](./${generatedFileName})`,
'',
].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) => `![Input](./${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');
}