diff --git a/package-lock.json b/package-lock.json index b7628c5..07baef9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.48.3", + "version": "0.50.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.48.3", + "version": "0.50.0", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", @@ -20,6 +20,7 @@ "node-fetch": "^3.3.2" }, "devDependencies": { + "@playwright/test": "^1.60.0", "puppeteer": "^24.42.0", "sharp": "^0.34.5" }, @@ -557,6 +558,22 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@puppeteer/browsers": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", @@ -1754,6 +1771,21 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2639,6 +2671,38 @@ "dev": true, "license": "ISC" }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", diff --git a/package.json b/package.json index 816b01e..01b4897 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "test:caldav": "node --experimental-sqlite test-caldav-sync.js", "test:carddav": "node --experimental-sqlite test-carddav.js", "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-multi-assignment.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav && npm run test:carddav", - "test:meal-planning": "node --experimental-sqlite test-meal-planning.js" + "test:meal-planning": "node --experimental-sqlite test-meal-planning.js", + "test:e2e:oikos-flow": "playwright test tests/e2e/oikos-kitchen-assist-flow.spec.mjs" }, "dependencies": { "bcrypt": "^6.0.0", @@ -50,6 +51,7 @@ }, "license": "MIT", "devDependencies": { + "@playwright/test": "^1.60.0", "puppeteer": "^24.42.0", "sharp": "^0.34.5" } diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 0000000..3cce271 --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,34 @@ +import { defineConfig, devices } from '@playwright/test'; +import fs from 'node:fs'; + +const baseURL = process.env.OIKOS_E2E_BASE_URL || 'https://home.friborg.uk'; +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); + +export default defineConfig({ + testDir: './tests/e2e', + timeout: 90_000, + expect: { timeout: 15_000 }, + fullyParallel: false, + retries: process.env.CI ? 1 : 0, + reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]], + use: { + baseURL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + ignoreHTTPSErrors: true, + actionTimeout: 15_000, + navigationTimeout: 30_000, + }, + projects: [ + { + name: 'chromium-desktop', + use: { + ...devices['Desktop Chrome'], + launchOptions: systemChromium ? { executablePath: systemChromium } : undefined, + }, + }, + ], +}); diff --git a/tests/e2e/oikos-kitchen-assist-flow.spec.mjs b/tests/e2e/oikos-kitchen-assist-flow.spec.mjs new file mode 100644 index 0000000..c5e6bcd --- /dev/null +++ b/tests/e2e/oikos-kitchen-assist-flow.spec.mjs @@ -0,0 +1,103 @@ +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); +} + +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()); + }); + + 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); + + 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); + + expect(diagnostics.pageErrors, 'No browser page errors during Kitchen/Assist flow').toEqual([]); + await attachDiagnostics(testInfo, diagnostics); + }); +});