278 lines
16 KiB
JavaScript
278 lines
16 KiB
JavaScript
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-studio-confirm]')).toBeVisible({ timeout: 15_000 });
|
|
await expect(studioHost.locator('[data-assist-studio-title]')).toHaveCount(7, { timeout: 15_000 });
|
|
await expect(studioHost).toContainText(/Madlavere & familiepræferencer|Børnenes madplan|Måltidshistorik/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 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 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);
|
|
|
|
await openMealPlanStudioFromAssist(page, diagnostics);
|
|
|
|
assertStructuredStudioPlan(await readStudioPlan(page));
|
|
|
|
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.');
|
|
|
|
const firstSwap = studioHost.locator('[data-assist-studio-swap]').first();
|
|
await expect(firstSwap, 'generated Studio plan should expose at least one swap alternative').toBeVisible();
|
|
const swapResponsePromise = page.waitForResponse((res) => res.url().includes('/ai/api/meal-plan/feedback') && res.request().method() === 'POST');
|
|
await firstSwap.click();
|
|
const swapResponse = await swapResponsePromise;
|
|
expect(swapResponse.ok(), 'swap should be recorded as meal-plan feedback').toBeTruthy();
|
|
const swapPayload = await swapResponse.json();
|
|
expect(swapPayload.source, 'swap feedback should flow through the native meal-planning API').toBe('oikos-native-api');
|
|
|
|
const firstTitle = studioHost.locator('[data-assist-studio-title="0"]');
|
|
const originalTitle = await firstTitle.inputValue();
|
|
const editedTitle = `[E2E cleanup] ${originalTitle}`.slice(0, 120);
|
|
await firstTitle.fill(editedTitle);
|
|
const editResponsePromise = page.waitForResponse((res) => res.url().includes('/ai/api/meal-plan/feedback') && res.request().method() === 'POST');
|
|
await firstTitle.dispatchEvent('change');
|
|
const editResponse = await editResponsePromise;
|
|
expect(editResponse.ok(), 'title edit should be recorded as meal-plan feedback').toBeTruthy();
|
|
const editPayload = await editResponse.json();
|
|
expect(editPayload.source, 'edit feedback should flow through the native meal-planning API').toBe('oikos-native-api');
|
|
|
|
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]'))).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([]);
|
|
});
|
|
});
|