313 lines
14 KiB
JavaScript
313 lines
14 KiB
JavaScript
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.');
|
|
});
|
|
});
|