From cef366cce40cde692fa29eb38b8e15e5928cb74e Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Tue, 12 May 2026 13:05:37 +0200 Subject: [PATCH] test: cover meal studio confirm and cleanup flow --- playwright.config.mjs | 7 +- tests/e2e/oikos-kitchen-assist-flow.spec.mjs | 169 +++++++++++++++---- 2 files changed, 142 insertions(+), 34 deletions(-) diff --git a/playwright.config.mjs b/playwright.config.mjs index 3cce271..2145aa2 100644 --- a/playwright.config.mjs +++ b/playwright.config.mjs @@ -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, diff --git a/tests/e2e/oikos-kitchen-assist-flow.spec.mjs b/tests/e2e/oikos-kitchen-assist-flow.spec.mjs index c5e6bcd..62cc7aa 100644 --- a/tests/e2e/oikos-kitchen-assist-flow.spec.mjs +++ b/tests/e2e/oikos-kitchen-assist-flow.spec.mjs @@ -41,24 +41,88 @@ async function login(page) { 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 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 = { - 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()); - }); + 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([]); + }); });