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; async function attachDiagnostics(testInfo, diagnostics) { await testInfo.attach('oikos-flow-diagnostics.json', { body: JSON.stringify(diagnostics, null, 2), contentType: 'application/json', }); } async function dismissOnboardingIfPresent(page) { const skip = page.getByRole('button', { name: /^(Skip|Spring over|Senere|Close|Luk)$/i }).first(); try { if (await skip.isVisible({ timeout: 2_000 })) await skip.click(); } catch { // Onboarding is optional; ignore when it is not present. } } async function login(page) { if (!username || !password) { throw new Error('Set OIKOS_E2E_USERNAME and OIKOS_E2E_PASSWORD_FILE (preferred) or OIKOS_E2E_PASSWORD for the live Oikos E2E flow.'); } 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 with HTTP ${loginResponse.status()}`); await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 20_000 }); await expect(page.locator('#main-content')).toBeVisible(); await dismissOnboardingIfPresent(page); } async function openMealPlanStudioFromAssist(page, diagnostics, prompt = 'Lav en praktisk madplan for næste uge ud fra vores opskrifter.') { const assistStart = Date.now(); await page.waitForFunction(() => window.oikos?.openAssist, { timeout: 15_000 }); await page.evaluate((message) => window.oikos?.openAssist?.({ prompt: message, expanded: true }), prompt); await expect(page.locator('#oikos-assist-root .assist-panel--open')).toBeVisible(); await expect(page.locator('#oikos-assist-root .assist-message--ai').last()).not.toContainText('Tænker…', { timeout: 60_000 }); diagnostics.timings.assistMealPlanMs = Date.now() - assistStart; const action = page.locator('[data-assist-kitchen-studio]').last(); await expect(action, 'Assist meal-plan response should include “Åbn forslag i Køkken”, not only text').toBeVisible({ timeout: 10_000 }); await action.click(); await page.waitForURL(/\/meals(?:$|[?#])/, { timeout: 15_000 }); const studioHost = page.locator('[data-oikos-meal-plan-studio]'); await expect(studioHost.locator('[data-assist-wizard="day"]'), 'Studio should open into the guided day-by-day wizard, not the old all-days form').toBeVisible({ timeout: 15_000 }); await expect(studioHost.locator('.assist-wizard-steps [data-assist-wizard-step]'), 'wizard should expose the five-step planning contract').toHaveCount(5, { timeout: 15_000 }); await expect(studioHost.locator('[data-assist-wizard-day]'), 'wizard should summarize every generated day').toHaveCount(7, { timeout: 15_000 }); await expect(studioHost).toContainText(/Dagstype|Én beslutning ad gangen|Avanceret/i); return studioHost; } async function readStudioPlan(page) { return page.evaluate(() => JSON.parse(localStorage.getItem('oikos-meal-plan-studio-v1') || 'null')); } function assertStructuredStudioPlan(plan) { expect(plan?.slots, 'Studio plan should exist in localStorage for structural assertions').toHaveLength(7); const categories = plan.slots.map((slot) => slot.meal_category || slot.variation?.category || 'other'); expect(categories.every((category) => category && category !== 'other'), 'all Studio slots should carry structured categories').toBeTruthy(); for (let i = 1; i < categories.length; i += 1) { expect(categories[i], `planner should not repeat category on consecutive days (${i - 1}/${i})`).not.toBe(categories[i - 1]); } const counts = categories.reduce((acc, category) => ({ ...acc, [category]: (acc[category] || 0) + 1 }), {}); expect(Math.max(...Object.values(counts)), `planner should not overuse one category: ${JSON.stringify(counts)}`).toBeLessThanOrEqual(2); const leftovers = plan.slots.filter((slot) => (slot.meal_category || slot.variation?.category) === 'leftovers' || slot.leftover_from_meal_id); expect(leftovers.length, 'leftovers should be represented as a dedicated structured option when meal history exists').toBeGreaterThanOrEqual(1); expect(leftovers.every((slot) => slot.leftover_from_meal_id || slot.context?.leftoverSource?.id), 'leftover slots should link to a source dish id').toBeTruthy(); expect(plan.modulators?.activeSignalTypes || [], 'planner should declare the family-signal modulators it scores against').toEqual(expect.arrayContaining(['favorites', 'canCook', 'adultOnly'])); expect(plan.slots.every((slot) => Array.isArray(slot.context?.modulators?.labels) && slot.context.modulators.labels.length >= 3), 'each Studio slot should expose generation modulators, not just a chosen recipe').toBeTruthy(); expect(plan.slots.some((slot) => slot.context?.modulators?.easyDay || slot.context?.modulators?.guests || slot.context?.modulators?.noKids || typeof slot.context?.modulators?.cookAge === 'number'), 'generated week should carry actionable day/person modulators').toBeTruthy(); } async function chooseWizardIntent(studioHost, dayIndex, intent = 'quick') { await expect(studioHost.locator('[data-assist-wizard="day"]')).toBeVisible({ timeout: 15_000 }); await studioHost.locator(`[data-assist-wizard-intent="${dayIndex}:${intent}"]`).click(); await expect(studioHost.locator('[data-assist-wizard="followup"]')).toBeVisible({ timeout: 15_000 }); } async function chooseWizardFollowupAndMeal(studioHost, dayIndex, { followup = 'time_20', refresh = false, manualTitle = '', chooseSuggestion = true } = {}) { const followupOption = studioHost.locator(`[data-assist-wizard-followup="${dayIndex}:${followup}"]`); if (await followupOption.count()) await followupOption.click(); if (refresh) { await studioHost.locator(`[data-assist-wizard-refresh="${dayIndex}"]`).click(); } else { await studioHost.locator('.assist-wizard-steps [data-assist-wizard-step="meal"]').click(); } await expect(studioHost.locator('[data-assist-wizard="meal"]')).toBeVisible({ timeout: 30_000 }); await expect(studioHost.locator(`[data-assist-suggestion-card^="${dayIndex}:"]`), 'meal step should expose suggestion cards before save').toHaveCount(3, { timeout: 15_000 }); if (manualTitle) { const manualForm = studioHost.locator(`[data-assist-wizard-manual="${dayIndex}"]`); await manualForm.locator('input[name="title"]').fill(manualTitle); await manualForm.locator('button[type="submit"]').click(); } else if (chooseSuggestion) { await studioHost.locator(`[data-assist-suggestion-card="${dayIndex}:0"]`).click(); } } async function completeWizardDay(studioHost, dayIndex) { await studioHost.locator(`[data-assist-wizard-complete-day="${dayIndex}"]`).click(); } async function completeWizardToReview(studioHost, { firstManualTitle = '', refreshFirstDay = false } = {}) { for (let dayIndex = 0; dayIndex < 7; dayIndex += 1) { await chooseWizardIntent(studioHost, dayIndex, dayIndex === 1 ? 'healthy' : 'quick'); await chooseWizardFollowupAndMeal(studioHost, dayIndex, { followup: dayIndex === 1 ? 'protein' : 'time_20', refresh: refreshFirstDay && dayIndex === 0, manualTitle: dayIndex === 0 ? firstManualTitle : '', chooseSuggestion: dayIndex !== 0 || !firstManualTitle, }); await completeWizardDay(studioHost, dayIndex); } await expect(studioHost.locator('[data-assist-wizard="review"]')).toBeVisible({ timeout: 15_000 }); await expect(studioHost.locator('[data-assist-studio-confirm]'), 'save action should appear only at the review step').toBeVisible({ timeout: 15_000 }); } async function deleteMeals(page, mealIds) { if (!mealIds.length) return []; return page.evaluate(async (ids) => { const csrf = document.cookie.split(';') .map((c) => c.trim()) .find((c) => c.startsWith('csrf-token=')) ?.slice('csrf-token='.length) || ''; const results = []; for (const id of ids) { const res = await fetch(`/api/v1/meals/${id}`, { method: 'DELETE', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': decodeURIComponent(csrf) }, }); results.push({ id, ok: res.ok, status: res.status }); } return results; }, mealIds); } async function listMealsById(page, mealIds, weeks = []) { return page.evaluate(async ({ ids, weeksToFetch }) => { const wanted = new Set(ids.map(String)); const results = []; const mondayFor = (value) => { const date = new Date(`${value}T12:00:00Z`); const day = date.getUTCDay() || 7; date.setUTCDate(date.getUTCDate() - day + 1); return date.toISOString().slice(0, 10); }; for (const week of [...new Set(weeksToFetch.filter(Boolean).map(mondayFor))]) { const res = await fetch(`/api/v1/meals?week=${encodeURIComponent(week)}`, { credentials: 'same-origin', cache: 'no-store' }); const payload = await res.json(); results.push(...(payload.data || []).filter((meal) => wanted.has(String(meal.id)))); } return results; }, { ids: mealIds, weeksToFetch: weeks.length ? weeks : [new Date().toISOString().slice(0, 10)] }); } function makeDiagnostics(page) { const diagnostics = { consoleErrors: [], pageErrors: [], failedRequests: [], timings: {}, urls: [], }; 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'}`)); page.on('framenavigated', (frame) => { if (frame === page.mainFrame()) diagnostics.urls.push(page.url()); }); return diagnostics; } test.describe('Oikos route guards and responsive edge coverage', () => { test('Protected kitchen routes send anonymous users to login instead of leaking a blank app', async ({ page }) => { for (const route of ['/', '/meals', '/shopping']) { await page.context().clearCookies(); await page.goto(route, { waitUntil: 'domcontentloaded' }); await expect(page.locator('#login-form')).toBeVisible({ timeout: 10_000 }); await expect(page.locator('#username')).toBeVisible(); } }); test('Kitchen/Studio works at mobile edge width without horizontal overflow', async ({ page }, testInfo) => { const diagnostics = makeDiagnostics(page); await page.setViewportSize({ width: 390, height: 844 }); await page.addInitScript(() => localStorage.setItem('oikos-onboarded', '1')); await login(page); await page.goto('/meals', { waitUntil: 'domcontentloaded' }); await dismissOnboardingIfPresent(page); const studioHost = page.locator('[data-oikos-meal-plan-studio]'); await expect(studioHost).toBeVisible({ timeout: 15_000 }); await expect(studioHost).toContainText(/Meal Plan Studio|Børnenes madplan/i); await expect(page.getByRole('button', { name: /Generér ugeplan|Regenerér|Meal Plan Studio/i }).first()).toBeVisible(); const overflow = await page.evaluate(() => ({ innerWidth: window.innerWidth, scrollWidth: document.documentElement.scrollWidth, bodyScrollWidth: document.body.scrollWidth, })); diagnostics.mobileOverflow = overflow; await attachDiagnostics(testInfo, diagnostics); expect(overflow.scrollWidth, `mobile page should not cause sideways scrolling: ${JSON.stringify(overflow)}`).toBeLessThanOrEqual(overflow.innerWidth + 12); expect(diagnostics.pageErrors, 'No browser page errors during mobile Kitchen smoke').toEqual([]); }); }); test.describe('Oikos Kitchen + Assist meal-planning flow', () => { test('Kitchen opens quickly, Studio is present, and Assist routes meal plans into Studio', async ({ page }, testInfo) => { const diagnostics = makeDiagnostics(page); await page.addInitScript(() => localStorage.setItem('oikos-onboarded', '1')); await login(page); await page.evaluate(() => localStorage.setItem('oikos-onboarded', '1')); await page.goto('/', { waitUntil: 'domcontentloaded' }); await dismissOnboardingIfPresent(page); const kitchenNav = page.locator('#sidebar-kitchen-nav, #kitchen-btn').filter({ visible: true }).first(); await expect(kitchenNav).toBeVisible(); const kitchenStart = Date.now(); await kitchenNav.click(); await page.waitForURL(/\/meals(?:$|[?#])/, { timeout: 15_000 }); await expect(page.locator('[data-oikos-meal-plan-studio]')).toBeVisible({ timeout: 15_000 }); await expect(page.getByRole('button', { name: /Meal Plan Studio|Generér ugeplan|Regenerér/i }).first()).toBeVisible(); diagnostics.timings.kitchenOpenMs = Date.now() - kitchenStart; expect(diagnostics.timings.kitchenOpenMs, 'Kitchen menu should open fast enough for real use').toBeLessThan(6_000); const studioHost = page.locator('[data-oikos-meal-plan-studio]'); await expect(studioHost).toContainText(/Meal Plan Studio/i); const routedStudioHost = await openMealPlanStudioFromAssist(page, diagnostics); await chooseWizardIntent(routedStudioHost, 0, 'quick'); await chooseWizardFollowupAndMeal(routedStudioHost, 0, { followup: 'time_20', refresh: true }); await completeWizardDay(routedStudioHost, 0); await expect(routedStudioHost.locator('[data-assist-wizard="day"]')).toBeVisible({ timeout: 15_000 }); const routedPlan = await readStudioPlan(page); assertStructuredStudioPlan(routedPlan); expect(routedPlan.daySetup?.[0]?.intent, 'wizard day intent should persist into the draft plan').toBe('quick'); expect(routedPlan.daySetup?.[0]?.followups || [], 'wizard follow-up choices should persist after suggestion refresh').toContain('time_20'); expect(diagnostics.pageErrors, 'No browser page errors during Kitchen/Assist flow').toEqual([]); await attachDiagnostics(testInfo, diagnostics); }); test('Studio supports swap/edit before confirming, then writes and cleans up meals', async ({ page }, testInfo) => { test.skip(process.env.OIKOS_E2E_MUTATE !== '1', 'Set OIKOS_E2E_MUTATE=1 to run the live write-and-cleanup confirmation test.'); const diagnostics = makeDiagnostics(page); const createdMealIds = []; await page.addInitScript(() => localStorage.setItem('oikos-onboarded', '1')); try { await login(page); await page.goto('/meals', { waitUntil: 'domcontentloaded' }); await dismissOnboardingIfPresent(page); const studioHost = await openMealPlanStudioFromAssist(page, diagnostics, 'Lav en testbar madplan for næste uge. Brug vores opskrifter og åbn den i Meal Plan Studio.'); await completeWizardToReview(studioHost, { firstManualTitle: '[E2E cleanup] Guided wizard dinner', refreshFirstDay: true }); assertStructuredStudioPlan(await readStudioPlan(page)); const actionResponsePromise = page.waitForResponse((res) => res.url().includes('/ai/api/action') && res.request().method() === 'POST'); await studioHost.locator('[data-assist-studio-confirm]').click(); const actionResponse = await actionResponsePromise; expect(actionResponse.ok(), 'Studio confirmation should call the Assist action endpoint successfully').toBeTruthy(); const actionPayload = await actionResponse.json(); expect(actionPayload.success).toBeTruthy(); const createdMeals = actionPayload.result?.data?.meals || []; const cookAssignments = actionPayload.result?.data?.cookAssignments || []; expect(cookAssignments.every((item) => item.source === 'oikos-native-api'), 'cook assignments should use the native meal-planning API when present').toBeTruthy(); createdMealIds.push(...createdMeals.map((meal) => meal?.id).filter(Boolean)); expect(createdMealIds, 'Studio confirmation should create seven Oikos meals').toHaveLength(7); const mealsInOikos = await listMealsById(page, createdMealIds, createdMeals.map((meal) => meal?.date)); expect(mealsInOikos, 'created meals should be readable from the native Oikos meals API').toHaveLength(7); expect(mealsInOikos.some((meal) => String(meal.title || '').includes('[E2E cleanup] Guided wizard dinner'))).toBeTruthy(); expect(mealsInOikos.every((meal) => meal.meal_category), 'confirmed meals should preserve structured meal categories').toBeTruthy(); const leftoverMeal = mealsInOikos.find((meal) => meal.meal_category === 'leftovers'); expect(leftoverMeal?.leftover_from_meal_id, 'confirmed leftover meal should link to a source dish').toBeTruthy(); const favoriteCandidate = mealsInOikos.find((meal) => meal.recipe_id); if (favoriteCandidate) { await page.goto(`/meals?week=${favoriteCandidate.date}`, { waitUntil: 'domcontentloaded' }); await dismissOnboardingIfPresent(page); const candidateCard = page.locator('.meal-card').filter({ hasText: favoriteCandidate.title }).first(); if (await candidateCard.count()) { await candidateCard.click(); } else { await page.locator('.meal-card').first().click(); } await expect(page.locator('#modal-recipe-pref-member')).toBeVisible(); let modalRecipeId = Number(await page.locator('#modal-recipe-id').inputValue()); if (!modalRecipeId) { await page.locator('#modal-recipe-id').selectOption(String(favoriteCandidate.recipe_id)); modalRecipeId = Number(await page.locator('#modal-recipe-id').inputValue()); } expect(modalRecipeId, 'favorite flow should originate from a meal modal with a saved recipe').toBeTruthy(); const memberValue = await page.locator('#modal-recipe-pref-member option').nth(1).getAttribute('value'); if (memberValue) { await page.locator('#modal-recipe-pref-member').selectOption(memberValue); const prefResponsePromise = page.waitForResponse((res) => res.url().includes(`/api/v1/meal-planning/recipe-signals/${modalRecipeId}`) && res.request().method() === 'PUT'); await page.locator('[data-meal-recipe-pref="favorite"]').click(); const prefResponse = await prefResponsePromise; expect(prefResponse.ok(), 'meal modal favorite action should write native recipe signals').toBeTruthy(); const members = await page.evaluate(async () => (await (await fetch('/api/v1/family/members', { credentials: 'same-origin' })).json()).data || []); const member = members.find((item) => String(item.id) === String(memberValue)); expect(Number(member?.favorite_meal_count || 0), 'family profile card data should reflect favorite meal count').toBeGreaterThanOrEqual(1); diagnostics.favoriteSignalCleanup = await page.evaluate(async ({ recipeId, userId }) => { const csrf = document.cookie.split(';').map((c) => c.trim()).find((c) => c.startsWith('csrf-token='))?.slice('csrf-token='.length) || ''; const res = await fetch(`/api/v1/meal-planning/recipe-signals/${recipeId}`, { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': decodeURIComponent(csrf) }, body: JSON.stringify({ user_id: userId, preference: 'neutral', can_cook: false, can_help_cook: false, will_eat_modified: false, adult_only: false }), }); return { ok: res.ok, status: res.status }; }, { recipeId: modalRecipeId, userId: memberValue }); } } } finally { const cleanup = await deleteMeals(page, createdMealIds).catch((err) => [{ error: err.message }]); diagnostics.cleanup = cleanup; await attachDiagnostics(testInfo, diagnostics); } expect(diagnostics.pageErrors, 'No browser page errors during confirm/write/cleanup flow').toEqual([]); }); });