test: add meal studio 50 use-case e2e
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,296 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user