import { expect, test } from '@playwright/test'; import fs from 'node:fs'; const username = process.env.OIKOS_E2E_USERNAME; const password = process.env.OIKOS_E2E_PASSWORD_FILE ? fs.readFileSync(process.env.OIKOS_E2E_PASSWORD_FILE, 'utf8').trim() : process.env.OIKOS_E2E_PASSWORD; const MODE_LABELS = { low_effort: 'Nemt', regular: 'Hverdag', flexible: 'Plads til mere', }; const MODIFIER_LABELS = { freezer: 'Fryser', leftovers: 'Rester', eating_out: 'Spiser ude', guests: 'Gæster', no_kids: 'Børnefri', cook_extra: 'Lav ekstra', rugbrod: 'Rugbrød/koldt', very_quick: 'Meget hurtigt', }; function isoAdd(start, offset) { const date = new Date(`${start}T12:00:00Z`); date.setUTCDate(date.getUTCDate() + offset); return date.toISOString().slice(0, 10); } function nextMonday() { const date = new Date(); date.setUTCHours(12, 0, 0, 0); const day = date.getUTCDay() || 7; date.setUTCDate(date.getUTCDate() + (8 - day)); return date.toISOString().slice(0, 10); } async function dismissOnboardingIfPresent(page) { const skip = page.getByRole('button', { name: /^(Skip|Spring over|Senere|Close|Luk)$/i }).first(); try { if (await skip.isVisible({ timeout: 1500 })) await skip.click(); } catch {} } async function login(page) { if (!username || !password) throw new Error('Missing OIKOS_E2E_USERNAME and password env.'); await page.goto('/login', { waitUntil: 'domcontentloaded' }); await page.locator('#username').fill(username); await page.locator('#password').fill(password); const loginResponsePromise = page.waitForResponse((res) => res.url().includes('/api/v1/auth/login')); await page.locator('#login-btn').click(); const loginResponse = await loginResponsePromise; if (!loginResponse.ok()) throw new Error(`Login failed HTTP ${loginResponse.status()}`); await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 20_000 }); await expect(page.locator('#main-content')).toBeVisible(); await dismissOnboardingIfPresent(page); } function buildUseCases(start) { const base = [ ['low_effort freezer quick', 'low_effort', ['freezer', 'very_quick'], 'freezer'], ['low_effort leftovers quick', 'low_effort', ['leftovers', 'very_quick'], 'leftovers'], ['low_effort rugbrod', 'low_effort', ['rugbrod'], 'quick'], ['low_effort kids normal', 'low_effort', [], 'easy'], ['regular no modifiers', 'regular', [], 'regular'], ['regular cook extra', 'regular', ['cook_extra'], 'batch'], ['regular leftovers', 'regular', ['leftovers'], 'leftovers'], ['regular freezer', 'regular', ['freezer'], 'freezer'], ['regular very quick', 'regular', ['very_quick'], 'quick'], ['flexible guests', 'flexible', ['guests'], 'guests'], ['flexible guests cook extra', 'flexible', ['guests', 'cook_extra'], 'guests_batch'], ['flexible no kids', 'flexible', ['no_kids'], 'adult'], ['flexible no kids eating out', 'flexible', ['no_kids', 'eating_out'], 'eating_out'], ['flexible eating out', 'flexible', ['eating_out'], 'eating_out'], ['flexible freezer guests', 'flexible', ['freezer', 'guests'], 'freezer_guests'], ['low effort guests conflict', 'low_effort', ['guests'], 'conflict'], ['low effort no kids', 'low_effort', ['no_kids'], 'adult_quick'], ['regular rugbrod kids', 'regular', ['rugbrod'], 'cold'], ['regular no kids cook extra', 'regular', ['no_kids', 'cook_extra'], 'adult_batch'], ['flexible all special', 'flexible', ['guests', 'cook_extra', 'freezer'], 'complex'], ['freezer only', 'regular', ['freezer'], 'inventory'], ['leftovers only', 'regular', ['leftovers'], 'inventory'], ['eating out only', 'regular', ['eating_out'], 'non_cooking'], ['cook extra only', 'regular', ['cook_extra'], 'batch'], ['very quick only', 'regular', ['very_quick'], 'quick'], ['rugbrod very quick', 'low_effort', ['rugbrod', 'very_quick'], 'cold_quick'], ['guests no kids', 'flexible', ['guests', 'no_kids'], 'adult_guests'], ['guests leftovers', 'flexible', ['guests', 'leftovers'], 'leftover_guests'], ['no kids freezer', 'flexible', ['no_kids', 'freezer'], 'adult_inventory'], ['kids easy freezer', 'low_effort', ['freezer'], 'kid_inventory'], ['busy monday', 'low_effort', ['very_quick'], 'calendar_pressure'], ['normal tuesday', 'regular', [], 'baseline'], ['ella cooks thursday', 'regular', ['very_quick'], 'kid_cook'], ['friday guests', 'flexible', ['guests'], 'weekend_guest'], ['saturday big cook', 'flexible', ['cook_extra'], 'weekend_batch'], ['sunday family regular', 'regular', [], 'sunday'], ['rainy comfort fallback', 'low_effort', [], 'weather'], ['warm grill flexible', 'flexible', [], 'weather'], ['partial plan one day', 'regular', [], 'partial'], ['two-day low effort streak', 'low_effort', ['very_quick'], 'variety'], ['avoid duplicate category', 'regular', [], 'variety'], ['manual freezer unknown', 'low_effort', ['freezer'], 'manual_inventory'], ['planned leftovers style', 'regular', ['leftovers'], 'planned_leftovers'], ['eating out still has cards', 'flexible', ['eating_out'], 'cards'], ['new suggestion distinct', 'regular', [], 'cards'], ['rare suggestion present', 'regular', [], 'cards'], ['known suggestion present', 'regular', [], 'cards'], ['modulator labels visible', 'flexible', ['guests', 'cook_extra'], 'ui'], ['inventory options on freezer', 'low_effort', ['freezer'], 'inventory'], ['reservation-compatible day', 'low_effort', ['freezer', 'very_quick'], 'inventory_reserve'], ]; return base.map(([name, mode, modifiers, intent], idx) => ({ id: idx + 1, name, date: isoAdd(start, idx % 14), mode, modifiers, intent, })); } function analyzeSlot(useCase, slot) { const issues = []; const mods = slot?.context?.modulators || {}; const cards = slot?.suggestionCards || []; if (!slot) issues.push('No slot returned for use case date.'); if (mods.dayMode !== useCase.mode) issues.push(`Expected dayMode ${useCase.mode}, got ${mods.dayMode}.`); for (const modifier of useCase.modifiers) { if (!(mods.manualModifiers || []).includes(modifier)) issues.push(`Missing manual modifier ${modifier}.`); } if (cards.length !== 3) issues.push(`Expected exactly 3 suggestion cards, got ${cards.length}.`); const titles = cards.map((card) => card.title).filter(Boolean); if (new Set(titles).size !== titles.length) issues.push(`Suggestion card titles are not distinct: ${titles.join(' | ')}`); if (useCase.modifiers.includes('eating_out') && !/spiser ude|takeaway|café/i.test(`${slot.title} ${titles.join(' ')}`)) issues.push('Eating-out use case did not produce an intentional eating-out/takeaway option.'); if (useCase.modifiers.includes('freezer') && !(slot.inventoryOptions || []).length) issues.push('Freezer use case did not expose inventoryOptions.'); if (useCase.modifiers.includes('freezer') && !/fryser|freezer/i.test(`${slot.title} ${titles.join(' ')}`)) issues.push('Freezer use case did not show freezer-oriented wording.'); if (useCase.modifiers.includes('leftovers') && !/rester|leftover|fryser/i.test(`${slot.title} ${titles.join(' ')}`)) issues.push('Leftovers use case did not show leftover-oriented wording.'); if (useCase.modifiers.includes('guests') && !/gæst|guest|stor|salat|ovnret/i.test(`${slot.reason} ${titles.join(' ')}`)) issues.push('Guests use case did not explain/scaffold guest fit.'); if (useCase.modifiers.includes('no_kids') && !/børnefri|voksen|adult|chili/i.test(`${slot.reason} ${titles.join(' ')}`)) issues.push('No-kids use case did not open adult/no-kids suggestions.'); if (useCase.mode === 'low_effort' && !mods.easyDay) issues.push('Low-effort use case was not marked as easyDay.'); if (!Array.isArray(mods.labels) || mods.labels.length < 2) issues.push('Modulator labels are too sparse for user inspection.'); return issues; } async function attachReport(testInfo, report) { await testInfo.attach('oikos-meal-studio-50-use-cases.json', { body: JSON.stringify(report, null, 2), contentType: 'application/json', }); const markdown = [ '# Oikos Meal Plan Studio — 50-use-case E2E verification', '', `Run: ${report.startedAt}`, `Base URL: ${report.baseURL}`, `Passed: ${report.summary.passed}/${report.summary.total}`, `Issues: ${report.summary.issueCount}`, '', '## Issues / defects / shortcomings', ...(report.issues.length ? report.issues.map((issue) => `- UC${String(issue.id).padStart(2, '0')} ${issue.name}: ${issue.issue}`) : ['- None recorded.']), '', '## Use-case results', ...report.results.map((row) => `- ${row.pass ? '✅' : '⚠️'} UC${String(row.id).padStart(2, '0')} ${row.name} — ${row.mode} [${row.modifiers.join(', ') || 'none'}] → ${row.slotTitle || 'no slot'}`), ].join('\n'); await testInfo.attach('oikos-meal-studio-50-use-cases.md', { body: markdown, contentType: 'text/markdown' }); } test.describe('Oikos Meal Plan Studio comprehensive 50-use-case verification', () => { test('50 realistic meal-planning use cases across API, UI, inventory, and mobile flow', async ({ page }, testInfo) => { test.setTimeout(240_000); const diagnostics = { consoleErrors: [], pageErrors: [], failedRequests: [] }; page.on('console', (msg) => { if (['error', 'warning'].includes(msg.type())) diagnostics.consoleErrors.push(`${msg.type()}: ${msg.text()}`); }); page.on('pageerror', (err) => diagnostics.pageErrors.push(err.stack || err.message)); page.on('requestfailed', (req) => diagnostics.failedRequests.push(`${req.method()} ${req.url()} :: ${req.failure()?.errorText || 'failed'}`)); await page.setViewportSize({ width: 390, height: 844 }); await page.addInitScript(() => localStorage.setItem('oikos-onboarded', '1')); await login(page); const startedAt = new Date().toISOString(); const baseURL = testInfo.project.use.baseURL || process.env.OIKOS_E2E_BASE_URL || 'https://home.friborg.uk'; const start = nextMonday(); const useCases = buildUseCases(start); const results = []; const issues = []; await test.step('health and served asset markers', async () => { const health = await page.request.get('/ai/health'); expect(health.ok()).toBeTruthy(); const widget = await page.request.get('/ai/widget.js'); expect(widget.ok()).toBeTruthy(); const widgetText = await widget.text(); for (const marker of ['assist-day-setup', 'assist-meal-options', 'assist-leftover-inventory', 'applySuggestionCard', 'reserveInventoryForDate']) { if (!widgetText.includes(marker)) issues.push({ id: 0, name: 'served widget marker', issue: `Missing widget marker ${marker}` }); } const css = await page.request.get('/ai/assist.css'); const cssText = await css.text(); for (const marker of ['Three-card meal picker slice', 'Leftover/freezer inventory picker slice']) { if (!cssText.includes(marker)) issues.push({ id: 0, name: 'served CSS marker', issue: `Missing CSS marker ${marker}` }); } }); await test.step('inventory API add/reserve/discard smoke', async () => { const uniqueTitle = `E2E fryser portion ${Date.now()}`; const create = await page.request.post('/ai/api/meal-plan/leftovers', { data: { title: uniqueTitle, servings: 3, location: 'freezer', expiresAt: isoAdd(start, 30), notes: 'E2E verification item' } }); const created = await create.json(); if (!create.ok() || !created.item?.id) issues.push({ id: 0, name: 'inventory create', issue: `Could not create inventory item: HTTP ${create.status()}` }); else { const reserve = await page.request.patch('/ai/api/meal-plan/leftovers', { data: { id: created.item.id, patch: { status: 'reserved', reservedForDate: start } } }); if (!reserve.ok()) issues.push({ id: 0, name: 'inventory reserve', issue: `Could not reserve inventory item: HTTP ${reserve.status()}` }); const discard = await page.request.patch('/ai/api/meal-plan/leftovers', { data: { id: created.item.id, patch: { status: 'discarded' } } }); if (!discard.ok()) issues.push({ id: 0, name: 'inventory cleanup', issue: `Could not discard E2E inventory item: HTTP ${discard.status()}` }); } }); for (const useCase of useCases) { await test.step(`UC${String(useCase.id).padStart(2, '0')} ${useCase.name}`, async () => { const response = await page.request.post('/ai/api/meal-plan/generate', { data: { startDate: useCase.date, endDate: useCase.date, dayConfigs: [{ date: useCase.date, mode: useCase.mode, modifiers: useCase.modifiers }], }, }); let payload = null; if (!response.ok()) { const issue = `Generation failed HTTP ${response.status()}`; issues.push({ id: useCase.id, name: useCase.name, issue }); results.push({ ...useCase, pass: false, issues: [issue] }); return; } payload = await response.json(); const slot = payload.slots?.[0]; const rowIssues = analyzeSlot(useCase, slot); for (const issue of rowIssues) issues.push({ id: useCase.id, name: useCase.name, issue }); results.push({ ...useCase, pass: rowIssues.length === 0, issues: rowIssues, slotTitle: slot?.title, cardTitles: (slot?.suggestionCards || []).map((card) => card.title), inventoryOptionCount: slot?.inventoryOptions?.length || 0, modulatorLabels: slot?.context?.modulators?.labels || [], }); }); } await test.step('mobile UI smoke for day chips, 3 cards, and inventory panel', async () => { await page.goto('/meals', { waitUntil: 'domcontentloaded' }); await dismissOnboardingIfPresent(page); const host = page.locator('[data-oikos-meal-plan-studio]'); await expect(host).toBeVisible({ timeout: 20_000 }); await host.locator('[data-assist-studio-generate]').first().click(); await expect(host.locator('.assist-day-setup')).toBeVisible({ timeout: 30_000 }); await expect(host.locator('.assist-meal-options').first()).toBeVisible({ timeout: 20_000 }); await expect(host.locator('.assist-leftover-inventory')).toBeVisible({ timeout: 20_000 }); const dayModeButtons = await host.locator('[data-assist-day-mode]').count(); const modifierButtons = await host.locator('[data-assist-day-modifier]').count(); const optionCards = await host.locator('[data-assist-suggestion-card]').count(); const overflow = await page.evaluate(() => ({ innerWidth: window.innerWidth, scrollWidth: document.documentElement.scrollWidth, bodyScrollWidth: document.body.scrollWidth })); if (dayModeButtons < 21) issues.push({ id: 0, name: 'mobile day mode UI', issue: `Expected day mode buttons for 7 days, got ${dayModeButtons}` }); if (modifierButtons < 56) issues.push({ id: 0, name: 'mobile modifier UI', issue: `Expected modifier buttons for 7 days, got ${modifierButtons}` }); if (optionCards < 21) issues.push({ id: 0, name: 'mobile suggestion cards', issue: `Expected 3 option cards for 7 days, got ${optionCards}` }); if (overflow.scrollWidth > overflow.innerWidth + 12) issues.push({ id: 0, name: 'mobile overflow', issue: `Horizontal overflow: ${JSON.stringify(overflow)}` }); const firstTitleBefore = await host.locator('[data-assist-studio-title="0"]').inputValue(); const secondCard = host.locator('[data-assist-suggestion-card]').nth(1); if (await secondCard.count()) { await secondCard.click(); const firstTitleAfter = await host.locator('[data-assist-studio-title="0"]').inputValue(); if (firstTitleBefore === firstTitleAfter) issues.push({ id: 0, name: 'suggestion card selection', issue: 'Clicking second suggestion card did not update first slot title.' }); } }); const report = { startedAt, baseURL, summary: { total: useCases.length, passed: results.filter((row) => row.pass).length, failed: results.filter((row) => !row.pass).length, issueCount: issues.length, }, issues, diagnostics, results, }; await attachReport(testInfo, report); fs.mkdirSync('artifacts', { recursive: true }); fs.writeFileSync('artifacts/oikos-meal-studio-50-use-cases.json', JSON.stringify(report, null, 2)); expect(results).toHaveLength(50); expect(diagnostics.pageErrors, 'No browser page errors').toEqual([]); expect(issues, `Recorded issues:\n${issues.map((i) => `UC${i.id} ${i.name}: ${i.issue}`).join('\n')}`).toEqual([]); }); });