const { test, expect } = require('@playwright/test'); const png = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', 'base64', ); async function mockModels(page) { let models = [ { id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' }, ]; await page.route('**/api/models', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ defaultModel: 'claude-haiku-4.5', models, }), }); }); await page.route('**/api/models/search', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ count: 2, models: [ { id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6' }, { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' }, ], }), }); }); await page.route('**/api/models/add', async (route) => { const body = route.request().postDataJSON(); models = [...models.filter(model => model.id !== body.id), { id: body.id, name: body.name || body.id }]; await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, defaultModel: 'claude-haiku-4.5', models, }), }); }); await page.route('**/api/models/remove', async (route) => { const body = route.request().postDataJSON(); models = models.filter(model => model.id !== body.id); if (!models.length) models = [{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' }]; await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, defaultModel: models[0].id, models }), }); }); } async function mockPlans(page) { let plans = []; let selectedPlanId = ''; await page.route('**/api/plans', async (route) => { if (route.request().method() === 'GET') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ plans, selectedPlanId }) }); } const plan = route.request().postDataJSON(); plans = [plan, ...plans]; selectedPlanId = plan.id; return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ plans, selectedPlanId }) }); }); await page.route('**/api/plans/select', async (route) => { selectedPlanId = route.request().postDataJSON().id || ''; await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ plans, selectedPlanId }) }); }); await page.route('**/api/plans/delete', async (route) => { const id = route.request().postDataJSON().id; plans = plans.filter(plan => plan.id !== id); if (selectedPlanId === id) selectedPlanId = plans[0]?.id || ''; await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ plans, selectedPlanId }) }); }); } async function mockEntries(page) { let entries = []; let trash = []; await page.route('**/api/entries', async (route) => { if (route.request().method() === 'GET') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ entries, trash }) }); } const entry = route.request().postDataJSON(); const idx = entries.findIndex(e => e.id === entry.id); if (idx >= 0) entries[idx] = { ...entries[idx], ...entry }; else entries = [...entries, entry]; return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ entries, trash }) }); }); await page.route('**/api/entries/delete', async (route) => { const { ids } = route.request().postDataJSON(); const idSet = new Set(ids); const moving = entries.filter(e => idSet.has(e.id)).map(e => ({ ...e, trashedAt: new Date().toISOString() })); entries = entries.filter(e => !idSet.has(e.id)); trash = [...moving, ...trash]; await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ entries, trash }) }); }); await page.route('**/api/entries/restore', async (route) => { const { id } = route.request().postDataJSON(); const found = trash.find(e => e.id === id); if (found) { const { trashedAt, ...restored } = found; entries = [restored, ...entries]; trash = trash.filter(e => e.id !== id); } await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ entries, trash }) }); }); } async function mockActivities(page) { let activities = []; await page.route('**/api/activities', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ activities }) }); }); await page.route('**/api/activities/sync', async (route) => { const body = route.request().postDataJSON(); activities = [...(body.activities || []), ...activities]; await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ activities }) }); }); } async function mockSettings(page) { let settings = {}; await page.route('**/api/settings', async (route) => { if (route.request().method() === 'GET') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(settings) }); } settings = { ...settings, ...route.request().postDataJSON() }; return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(settings) }); }); } async function login(page) { await mockModels(page); await mockPlans(page); await mockEntries(page); await mockActivities(page); await mockSettings(page); await page.goto('/'); await expect(page).toHaveURL(/\/login$/); await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible(); await page.locator('#password').fill('test-password'); await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); } test('requires authentication before loading the app', async ({ page, request }) => { await page.goto('/'); await expect(page).toHaveURL(/\/login$/); await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible(); const response = await request.post('/api/chat', { data: {} }); expect(response.status()).toBe(401); }); test.describe('authenticated app', () => { test.beforeEach(async ({ page }) => { await login(page); await page.evaluate(() => localStorage.clear()); await page.reload(); await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); }); test('uses direct page navigation and validates missing meal input', async ({ page }) => { await expect(page.getByRole('navigation', { name: 'Main menu' })).toBeVisible(); await expect(page.locator('#menu-search')).toHaveCount(0); await expect(page.locator('#todayCalories')).toHaveText('0 kcal'); await page.locator('nav').getByRole('button').filter({ hasText: 'Log meal' }).click(); await page.getByRole('button', { name: 'Analyze and save' }).click(); await expect(page.locator('#status')).toContainText('Describe the meal or upload an image.'); }); test('generates and fine-tunes versioned plans without raw markdown', async ({ page }) => { let planCalls = 0; await page.route('**/api/chat', async (route) => { planCalls += 1; const content = planCalls === 1 ? '**Targets**\n- Calories: 1,700-1,900 daily\n- Protein: 120 g daily\n---\nHabits\n- Walk after dinner' : 'Targets\nCalories: 1,650-1,850 daily\nProtein: 125 g daily\nHabits\nWalk 7,000 steps daily'; await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ choices: [{ message: { content } }] }), }); }); await page.locator('nav').getByRole('button').filter({ hasText: 'Plans' }).click(); await expect(page.getByRole('heading', { name: 'Plans' })).toBeVisible(); await page.getByLabel('Sex').selectOption('Male'); await page.getByLabel('Age').fill('36'); await page.getByLabel('Height (cm)').fill('178'); await page.getByLabel('Current weight (kg)').fill('91'); await page.getByLabel('Target weight (kg)').fill('82'); await page.getByLabel('Why this matters').selectOption('Improve my overall health'); await page.getByLabel('Biggest challenge').selectOption('Being too busy'); await page.getByLabel('Tracking style').selectOption('Photo log meals'); await expect(page.getByText('Calculated target')).toBeVisible(); await page.getByRole('button', { name: 'Generate new plan' }).click(); await expect(page.getByText('Plan generated.')).toBeVisible(); await expect(page.getByText('Targets')).toBeVisible(); await expect(page.getByText('**Targets**')).toHaveCount(0); await expect(page.getByText('---')).toHaveCount(0); await page.getByLabel('Fine-tune with meals, activities, or progress notes').fill('I walked 7,000 steps and felt hungry at night.'); await page.getByRole('button', { name: 'Update plan with AI' }).click(); await expect(page.getByText('Plan updated.')).toBeVisible(); await expect(page.getByText('Updated plan 2').first()).toBeVisible(); await expect(page.getByText('Weight-loss plan 1')).toBeVisible(); }); test('saves account email and requests verification from settings', async ({ page }) => { await page.route('**/api/account', async (route) => { const body = route.request().postDataJSON(); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, email: body.email, emailVerified: false }), }); }); await page.route('**/api/account/send-verification', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, message: 'Verification email sent.' }), }); }); await page.locator('nav').getByRole('button').filter({ hasText: 'Settings' }).click(); await page.getByLabel('Account email').fill('tester@example.com'); await page.getByRole('button', { name: 'Save email' }).click(); await expect(page.locator('#accountStatus')).toContainText('Account email saved.'); await expect(page.getByText('Email not verified')).toBeVisible(); await page.getByRole('button', { name: 'Send verification email' }).click(); await expect(page.locator('#accountStatus')).toContainText('Verification email sent.'); }); test('adds curated models in settings, backfills a meal, edits, trashes, and restores', async ({ page }) => { let calls = 0; await page.route('**/api/chat', async (route) => { calls += 1; const content = calls === 1 ? '{"foods":["salmon","rice","broccoli","berries"],"calories":620,"proteinGrams":42,"carbsGrams":58,"fatGrams":22,"fruitServings":0.5,"vegetableServings":1.5}' : '{"mealName":"Salmon rice bowl","calories":640,"proteinGrams":44,"carbsGrams":60,"fatGrams":23,"fruitServings":0.5,"vegetableServings":1.5,"foodGroups":"fish, grains, fruit, vegetables","notes":"Estimated from image and portion note."}'; await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ choices: [{ message: { content } }] }), }); }); await page.locator('nav').getByRole('button').filter({ hasText: 'Settings' }).click(); await page.locator('#modelSearch').fill('gemini'); await page.getByRole('button', { name: 'Search' }).click(); await expect(page.getByText('Gemini 2.5 Flash')).toBeVisible(); await page.getByRole('button', { name: 'Add' }).last().click(); await expect(page.locator('#modelStatus')).toContainText('Added Gemini 2.5 Flash.'); await page.locator('#visionModel').selectOption('gemini-2.5-flash'); await page.locator('#taskModel').selectOption('gemini-2.5-flash'); await page.getByRole('button', { name: 'Save selected models' }).click(); await expect(page.locator('#modelStatus')).toContainText('Models saved.'); await page.getByRole('button', { name: 'Remove' }).last().click(); await expect(page.locator('#modelStatus')).toContainText('Model removed.'); await page.getByRole('button', { name: 'Log meal' }).click(); await page.locator('#mealDate').fill('2026-05-18'); await page.locator('#mealTime').fill('08:15'); await page.locator('#mealType').selectOption('Breakfast'); await page.locator('#description').fill('salmon rice bowl with broccoli and berries'); await page.locator('#measure').fill('one dinner bowl'); await page.locator('#image').setInputFiles({ name: 'meal.png', mimeType: 'image/png', buffer: png }); await expect(page.locator('#preview')).toBeVisible(); await page.getByRole('button', { name: 'Analyze and save' }).click(); await expect(page.locator('#status')).toContainText('Saved.'); await page.locator('nav').getByRole('button').filter({ hasText: 'Diary' }).click(); await expect(page.locator('.entry')).toContainText('Salmon rice bowl'); await expect(page.locator('.entry')).toContainText('2026-05-18 08:15 ยท Breakfast'); await expect(page.getByRole('button', { name: 'Move selected to trash' })).toHaveCount(0); await page.getByRole('checkbox').check(); await expect(page.getByRole('button', { name: 'Edit selected' })).toBeVisible(); await page.getByRole('button', { name: 'Edit selected' }).click(); await expect(page.getByRole('heading', { name: 'Edit meal' })).toBeVisible(); await page.getByLabel('Calories').fill('610'); await page.getByRole('button', { name: 'Save edits' }).click(); await page.locator('nav').getByRole('button').filter({ hasText: 'Analytics' }).click(); await expect(page.locator('#calorieChart')).toBeVisible(); await expect(page.locator('#produceChart')).toBeVisible(); await page.locator('nav').getByRole('button').filter({ hasText: 'Diary' }).click(); await page.getByRole('checkbox').check(); await page.getByRole('button', { name: 'Move selected to trash' }).click(); await expect(page.locator('#entries')).toContainText('No meals match the current filters.'); await page.locator('nav').getByRole('button').filter({ hasText: 'Trash' }).click(); await expect(page.locator('#trashEntries')).toContainText('Salmon rice bowl'); await page.getByRole('button', { name: 'Restore' }).click(); await expect(page.locator('#trashEntries')).toContainText('Trash is empty.'); }); });