test: cover meal studio confirm and cleanup flow
This commit is contained in:
@@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const baseURL = process.env.OIKOS_E2E_BASE_URL || 'https://home.friborg.uk';
|
||||
const keepArtifacts = process.env.OIKOS_E2E_ARTIFACTS === '1';
|
||||
const systemChromium = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| (fs.existsSync('/usr/bin/chromium-browser') ? '/usr/bin/chromium-browser' : undefined)
|
||||
|| (fs.existsSync('/snap/bin/chromium') ? '/snap/bin/chromium' : undefined);
|
||||
@@ -15,9 +16,9 @@ export default defineConfig({
|
||||
reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
trace: keepArtifacts ? 'retain-on-failure' : 'off',
|
||||
screenshot: keepArtifacts ? 'only-on-failure' : 'off',
|
||||
video: keepArtifacts ? 'retain-on-failure' : 'off',
|
||||
ignoreHTTPSErrors: true,
|
||||
actionTimeout: 15_000,
|
||||
navigationTimeout: 30_000,
|
||||
|
||||
@@ -41,8 +41,66 @@ async function login(page) {
|
||||
await dismissOnboardingIfPresent(page);
|
||||
}
|
||||
|
||||
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) => {
|
||||
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 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: [],
|
||||
@@ -59,6 +117,12 @@ test.describe('Oikos Kitchen + Assist meal-planning flow', () => {
|
||||
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);
|
||||
@@ -81,23 +145,66 @@ test.describe('Oikos Kitchen + Assist meal-planning flow', () => {
|
||||
const studioHost = page.locator('[data-oikos-meal-plan-studio]');
|
||||
await expect(studioHost).toContainText(/Meal Plan Studio/i);
|
||||
|
||||
const assistPrompt = 'Lav en praktisk madplan for næste uge ud fra vores opskrifter.';
|
||||
const assistStart = Date.now();
|
||||
await page.evaluate((prompt) => window.oikos?.openAssist?.({ prompt, expanded: true }), assistPrompt);
|
||||
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 });
|
||||
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);
|
||||
await openMealPlanStudioFromAssist(page, diagnostics);
|
||||
|
||||
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');
|
||||
|
||||
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();
|
||||
} 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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user