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([]); }); });