diff --git a/tests/e2e/oikos-everyday-flows.spec.mjs b/tests/e2e/oikos-everyday-flows.spec.mjs new file mode 100644 index 0000000..61c7bbb --- /dev/null +++ b/tests/e2e/oikos-everyday-flows.spec.mjs @@ -0,0 +1,349 @@ +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 mutate = process.env.OIKOS_E2E_MUTATE === '1'; + +function seededRandom(seedText) { + let seed = 0; + for (const char of seedText) seed = (seed * 31 + char.charCodeAt(0)) >>> 0; + return () => { + seed = (seed * 1664525 + 1013904223) >>> 0; + return seed / 0x100000000; + }; +} + +function pick(rand, values) { return values[Math.floor(rand() * values.length)]; } +function stamp(testInfo) { return `E2E-${Date.now().toString(36)}-${testInfo.workerIndex}-${Math.floor(Math.random() * 1e6).toString(36)}`; } +function futureDate(days) { const d = new Date(); d.setDate(d.getDate() + days); return d.toISOString().slice(0, 10); } + +async function attachDiagnostics(testInfo, diagnostics) { + await testInfo.attach('everyday-flow-diagnostics.json', { + body: JSON.stringify(diagnostics, null, 2), + contentType: 'application/json', + }); +} + +function makeDiagnostics(page) { + const diagnostics = { consoleErrors: [], pageErrors: [], failedRequests: [], 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; +} + +async function gotoApp(page, route) { + try { + await page.goto(route, { waitUntil: 'domcontentloaded' }); + } catch (err) { + if (!String(err.message || '').includes('ERR_ABORTED')) throw err; + await page.waitForLoadState('domcontentloaded').catch(() => {}); + if (!page.url().includes(route.replace(/^\//, ''))) { + await page.goto(route, { waitUntil: 'domcontentloaded' }); + } + } +} + +async function dismissOnboardingIfPresent(page) { + const skip = page.getByRole('button', { name: /^(Skip|Spring over|Senere|Close|Luk)$/i }).first(); + try { if (await skip.isVisible({ timeout: 1_000 })) await skip.click(); } catch {} +} + +async function login(page) { + if (!username || !password) throw new Error('Set OIKOS_E2E_USERNAME and OIKOS_E2E_PASSWORD_FILE or OIKOS_E2E_PASSWORD.'); + await page.addInitScript(() => localStorage.setItem('oikos-onboarded', '1')); + 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 dismissOnboardingIfPresent(page); +} + +async function api(page, path, options = {}) { + if (page.isClosed()) throw new Error(`Cannot call API on closed page: ${path}`); + if (page.url() === 'about:blank') await page.goto('/', { waitUntil: 'domcontentloaded' }); + try { + return await page.evaluate(async ({ path, options }) => { + 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${path}`, { + credentials: 'same-origin', + cache: 'no-store', + ...options, + headers: { + 'Content-Type': 'application/json', + ...(options.method && options.method !== 'GET' ? { 'X-CSRF-Token': decodeURIComponent(csrf) } : {}), + ...(options.headers || {}), + }, + body: options.body && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body, + }); + const data = await res.json().catch(() => null); + return { ok: res.ok, status: res.status, data }; + }, { path, options }); + } catch (err) { + if (!String(err.message || '').includes('Execution context was destroyed')) throw err; + await page.waitForLoadState('domcontentloaded').catch(() => {}); + return page.evaluate(async ({ path, options }) => { + 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${path}`, { + credentials: 'same-origin', cache: 'no-store', ...options, + headers: { 'Content-Type': 'application/json', ...(options.method && options.method !== 'GET' ? { 'X-CSRF-Token': decodeURIComponent(csrf) } : {}), ...(options.headers || {}) }, + body: options.body && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body, + }); + const data = await res.json().catch(() => null); + return { ok: res.ok, status: res.status, data }; + }, { path, options }); + } +} + +async function cleanupTaggedData(page, tag) { + const results = {}; + const safeDelete = async (label, listPath, extract, deletePath) => { + try { + const listed = await api(page, listPath); + const rows = extract(listed.data).filter((item) => JSON.stringify(item).includes(tag)); + results[label] = []; + for (const row of rows) { + const del = await api(page, deletePath(row), { method: 'DELETE' }); + results[label].push({ id: row.id, ok: del.ok, status: del.status }); + } + } catch (err) { results[label] = [{ error: err.message }]; } + }; + + await safeDelete('tasks', '/tasks?status=open', (d) => d?.data || [], (row) => `/tasks/${row.id}`); + await safeDelete('tasksDone', '/tasks?status=done', (d) => d?.data || [], (row) => `/tasks/${row.id}`); + await safeDelete('notes', '/notes', (d) => d?.data || [], (row) => `/notes/${row.id}`); + await safeDelete('contacts', '/contacts', (d) => d?.data || [], (row) => `/contacts/${row.id}`); + await safeDelete('events', `/calendar?start=${futureDate(-7)}&end=${futureDate(45)}`, (d) => d?.data || [], (row) => `/calendar/${row.id}`); + await safeDelete('shoppingLists', '/shopping', (d) => d?.data || [], (row) => `/shopping/${row.id}`); + + const meals = await api(page, `/meals?week=${futureDate(0)}`); + results.meals = []; + for (const meal of (meals.data?.data || []).filter((item) => JSON.stringify(item).includes(tag))) { + const del = await api(page, `/meals/${meal.id}`, { method: 'DELETE' }); + results.meals.push({ id: meal.id, ok: del.ok, status: del.status }); + } + return results; +} + +async function requireMutate() { + test.skip(!mutate, 'Set OIKOS_E2E_MUTATE=1 to run randomized everyday write flows.'); +} + +const everydayUseCases = [ + 'Anonymous family member opens protected app and gets routed to login', + 'Parent checks dashboard/household sections on desktop and mobile widths', + 'Parent creates, completes, and cleans up a practical chore task', + 'Family builds a dedicated grocery list with random staples and checks one item', + 'Meal planned for a week is converted into a shopping list', + 'Parent pins a sticky note, searches it, and deletes it', + 'Parent adds a calendar appointment with random time/location and deletes it', + 'Parent adds a contact/service provider and finds it via search', + 'Kitchen/Assist generates a meal-plan draft and exposes Studio actions', + 'Cross-module search finds freshly created household data', +]; + +test.describe('Oikos everyday randomized use-case matrix', () => { + test('documents the 10 everyday use cases under test', async ({}, testInfo) => { + await testInfo.attach('everyday-use-cases.json', { + body: JSON.stringify(everydayUseCases.map((flow, index) => ({ id: index + 1, flow })), null, 2), + contentType: 'application/json', + }); + expect(everydayUseCases).toHaveLength(10); + }); + + test('01 anonymous protected routes show login cleanly', async ({ page }, testInfo) => { + const diagnostics = makeDiagnostics(page); + for (const route of ['/', '/tasks', '/shopping', '/meals', '/calendar', '/notes']) { + await page.context().clearCookies(); + await gotoApp(page, route); + await expect(page.locator('#login-form')).toBeVisible({ timeout: 10_000 }); + } + await attachDiagnostics(testInfo, diagnostics); + expect(diagnostics.pageErrors).toEqual([]); + }); + + test('02 everyday navigation is usable on desktop and mobile', async ({ page }, testInfo) => { + const diagnostics = makeDiagnostics(page); + await login(page); + for (const size of [{ width: 1366, height: 900 }, { width: 390, height: 844 }]) { + await page.setViewportSize(size); + for (const route of ['/', '/tasks', '/shopping', '/meals', '/calendar', '/notes', '/contacts']) { + await gotoApp(page, route); + await dismissOnboardingIfPresent(page); + await expect(page.locator('#main-content')).toBeVisible({ timeout: 15_000 }); + const overflow = await page.evaluate(() => ({ width: window.innerWidth, scrollWidth: document.documentElement.scrollWidth })); + expect(overflow.scrollWidth, `${route} overflowed at ${size.width}px`).toBeLessThanOrEqual(overflow.width + 24); + } + } + await attachDiagnostics(testInfo, diagnostics); + expect(diagnostics.pageErrors).toEqual([]); + }); + + test('03 task chore can be created, completed, and cleaned up', async ({ page }, testInfo) => { + await requireMutate(); + const diagnostics = makeDiagnostics(page); + const rand = seededRandom(testInfo.title); + const tag = stamp(testInfo); + const title = `${pick(rand, ['Tøm opvaskemaskine', 'Pak skoletasker', 'Vand planter'])} ${tag}`; + await login(page); + try { + const created = await api(page, '/tasks', { method: 'POST', body: { title, description: `Random household chore ${tag}`, priority: pick(rand, ['low', 'medium', 'high']), category: pick(rand, ['household', 'school', 'shopping']), due_date: futureDate(1), status: 'open' } }); + expect(created.ok, JSON.stringify(created)).toBeTruthy(); + await gotoApp(page, '/tasks'); + await expect(page.locator('.task-card', { hasText: title })).toBeVisible(); + const done = await api(page, `/tasks/${created.data.data.id}/status`, { method: 'PATCH', body: { status: 'done' } }); + expect(done.ok, JSON.stringify(done)).toBeTruthy(); + await gotoApp(page, '/tasks'); + expect((await api(page, `/tasks/${created.data.data.id}`)).data?.data?.status).toBe('done'); + } finally { diagnostics.cleanup = await cleanupTaggedData(page, tag); } + await attachDiagnostics(testInfo, diagnostics); + expect(diagnostics.pageErrors).toEqual([]); + }); + + test('04 grocery list supports random staples, checking, and cleanup', async ({ page }, testInfo) => { + await requireMutate(); + const diagnostics = makeDiagnostics(page); + const rand = seededRandom(testInfo.title + Date.now()); + const tag = stamp(testInfo); + await login(page); + try { + const categories = (await api(page, '/shopping/categories')).data?.data || []; + const category = categories[0]?.name || 'Sonstiges'; + const listName = `Weekend indkøb ${tag}`; + const list = await api(page, '/shopping', { method: 'POST', body: { name: listName } }); + expect(list.ok, JSON.stringify(list)).toBeTruthy(); + const items = ['mælk', 'rugbrød', 'bananer', 'havregryn', 'kylling', 'agurk'].sort(() => rand() - 0.5).slice(0, 4); + for (const name of items) { + const item = await api(page, `/shopping/${list.data.data.id}/items`, { method: 'POST', body: { name: `${name} ${tag}`, quantity: `${1 + Math.floor(rand() * 4)} stk`, category } }); + expect(item.ok, JSON.stringify(item)).toBeTruthy(); + } + const listItems = await api(page, `/shopping/${list.data.data.id}/items`); + expect(listItems.data.data).toHaveLength(items.length); + const first = listItems.data.data[0]; + const checked = await api(page, `/shopping/items/${first.id}`, { method: 'PATCH', body: { is_checked: 1 } }); + expect(checked.ok, JSON.stringify(checked)).toBeTruthy(); + await gotoApp(page, '/shopping'); + await expect(page.locator('body')).toContainText(listName); + } finally { diagnostics.cleanup = await cleanupTaggedData(page, tag); } + await attachDiagnostics(testInfo, diagnostics); + expect(diagnostics.pageErrors).toEqual([]); + }); + + test('05 week meal ingredients convert to a shopping list', async ({ page }, testInfo) => { + await requireMutate(); + const diagnostics = makeDiagnostics(page); + const tag = stamp(testInfo); + await login(page); + try { + const categories = (await api(page, '/shopping/categories')).data?.data || []; + const category = categories[0]?.name || 'Sonstiges'; + const list = await api(page, '/shopping', { method: 'POST', body: { name: `Madplan indkøb ${tag}` } }); + expect(list.ok, JSON.stringify(list)).toBeTruthy(); + const meal = await api(page, '/meals', { method: 'POST', body: { title: `Pasta testmiddag ${tag}`, date: futureDate(2), meal_type: 'dinner', meal_category: 'pasta', ingredients: [{ name: `pasta ${tag}`, quantity: '500 g', category }, { name: `tomatsauce ${tag}`, quantity: '1 glas', category }] } }); + expect(meal.ok, JSON.stringify(meal)).toBeTruthy(); + const converted = await api(page, '/meals/week-to-shopping-list', { method: 'POST', body: { week: futureDate(2), listId: list.data.data.id } }); + expect(converted.ok, JSON.stringify(converted)).toBeTruthy(); + expect(converted.data.data.transferred).toBeGreaterThanOrEqual(2); + await gotoApp(page, '/shopping'); + await expect(page.locator('body')).toContainText(tag); + } finally { diagnostics.cleanup = await cleanupTaggedData(page, tag); } + await attachDiagnostics(testInfo, diagnostics); + expect(diagnostics.pageErrors).toEqual([]); + }); + + test('06 sticky note can be pinned, searched, and cleaned up', async ({ page }, testInfo) => { + await requireMutate(); + const diagnostics = makeDiagnostics(page); + const rand = seededRandom(testInfo.title); + const tag = stamp(testInfo); + const title = `Husk ${pick(rand, ['bibliotek', 'idrætstøj', 'madpakker'])} ${tag}`; + await login(page); + try { + const note = await api(page, '/notes', { method: 'POST', body: { title, content: `Random note content ${tag}`, color: '#FFEB3B', pinned: 1 } }); + expect(note.ok, JSON.stringify(note)).toBeTruthy(); + await gotoApp(page, '/notes'); + await page.locator('#notes-search').fill(tag); + await expect(page.locator('.note-card', { hasText: tag })).toBeVisible(); + } finally { diagnostics.cleanup = await cleanupTaggedData(page, tag); } + await attachDiagnostics(testInfo, diagnostics); + expect(diagnostics.pageErrors).toEqual([]); + }); + + test('07 calendar appointment can be added and found', async ({ page }, testInfo) => { + await requireMutate(); + const diagnostics = makeDiagnostics(page); + const rand = seededRandom(testInfo.title); + const tag = stamp(testInfo); + const title = `${pick(rand, ['Tandlæge', 'Skole-hjem', 'Fodbold'])} ${tag}`; + await login(page); + try { + const event = await api(page, '/calendar', { method: 'POST', body: { title, start_datetime: `${futureDate(3)}T15:00:00`, end_datetime: `${futureDate(3)}T16:00:00`, all_day: 0, location: `Lokation ${tag}`, description: `Random appointment ${tag}`, color: '#007AFF' } }); + expect(event.ok, JSON.stringify(event)).toBeTruthy(); + const upcoming = await api(page, '/calendar/upcoming'); + expect(JSON.stringify(upcoming.data)).toContain(tag); + await gotoApp(page, '/calendar'); + await expect(page.locator('body')).toContainText(tag); + } finally { diagnostics.cleanup = await cleanupTaggedData(page, tag); } + await attachDiagnostics(testInfo, diagnostics); + expect(diagnostics.pageErrors).toEqual([]); + }); + + test('08 contact/service provider can be added and searched', async ({ page }, testInfo) => { + await requireMutate(); + const diagnostics = makeDiagnostics(page); + const tag = stamp(testInfo); + await login(page); + try { + const contact = await api(page, '/contacts', { method: 'POST', body: { name: `VVS Kontakt ${tag}`, category: 'Handwerker', phone: '+4512345678', email: `e2e-${tag.toLowerCase()}@example.invalid`, address: 'Testvej 1', notes: `Random provider ${tag}` } }); + expect(contact.ok, JSON.stringify(contact)).toBeTruthy(); + await gotoApp(page, '/contacts'); + await page.locator('#contacts-search').fill(tag); + await expect(page.locator('.contact-item', { hasText: tag })).toBeVisible(); + } finally { diagnostics.cleanup = await cleanupTaggedData(page, tag); } + await attachDiagnostics(testInfo, diagnostics); + expect(diagnostics.pageErrors).toEqual([]); + }); + + test('09 Assist meal-plan draft opens Studio with actionable controls', async ({ page }, testInfo) => { + const diagnostics = makeDiagnostics(page); + await login(page); + await page.waitForFunction(() => window.oikos?.openAssist, { timeout: 15_000 }); + await page.evaluate(() => window.oikos.openAssist({ prompt: 'Lav en praktisk madplan for næste uge ud fra vores opskrifter.', expanded: true })); + 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 }); + const action = page.locator('[data-assist-kitchen-studio]').last(); + await expect(action).toHaveCount(1, { timeout: 10_000 }); + await expect(action).toContainText(/Åbn forslag|Køkken|Studio/i); + await page.evaluate(() => document.querySelector('[data-assist-kitchen-studio]')?.click()); + await expect(page).toHaveURL(/\/meals(?:$|[#?])/, { timeout: 15_000 }); + await expect(page.locator('[data-oikos-meal-plan-studio]')).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('[data-assist-studio-title]')).toHaveCount(7, { timeout: 15_000 }); + await attachDiagnostics(testInfo, diagnostics); + expect(diagnostics.pageErrors).toEqual([]); + }); + + test('10 global search finds freshly created household data', async ({ page }, testInfo) => { + await requireMutate(); + const diagnostics = makeDiagnostics(page); + const tag = stamp(testInfo); + await login(page); + try { + const task = await api(page, '/tasks', { method: 'POST', body: { title: `Findbar opgave ${tag}`, category: 'household', priority: 'medium', status: 'open' } }); + const note = await api(page, '/notes', { method: 'POST', body: { title: `Findbar note ${tag}`, content: `Søgbar note ${tag}`, color: '#A5D6A7', pinned: 0 } }); + expect(task.ok && note.ok, JSON.stringify({ task, note })).toBeTruthy(); + const search = await api(page, `/search?q=${encodeURIComponent(tag)}`); + expect(search.ok, JSON.stringify(search)).toBeTruthy(); + expect(JSON.stringify(search.data)).toContain(tag); + } finally { diagnostics.cleanup = await cleanupTaggedData(page, tag); } + await attachDiagnostics(testInfo, diagnostics); + expect(diagnostics.pageErrors).toEqual([]); + }); +});