48d30df07b
Backend (.NET 10 minimal API): - Vertical slice architecture with feature folders - Postgres via EF Core with initial migration - JWT auth with family invite code registration - REST endpoints for stores, shopping lists, items, recipes - SignalR hub for real-time list collaboration (per-list groups and lists-overview group for live list creation/archival/progress) - Multi-stage Dockerfile Frontend (SvelteKit + Svelte 5 runes, Tailwind v4): - Mobile-first PWA with web manifest and service worker - Bottom-nav layout, login/register, lists overview, list detail, stores management, recipes (list/create/detail with add-to-list) - SignalR client with reference-counted connection - Real-time updates on both lists overview and list detail pages Infrastructure: - docker-compose.yml with postgres, backend, frontend services and Traefik labels for path-based routing (/api, /hubs to backend) - .env.example with required config End-to-end tests (Playwright): - test-e2e.mjs: single-user flow (auth, stores, lists, items, recipes) - test-e2e-multiuser.mjs: two-user real-time sync coverage Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
148 lines
5.1 KiB
JavaScript
148 lines
5.1 KiB
JavaScript
import { chromium } from 'playwright';
|
|
|
|
const browser = await chromium.launch({ channel: 'chrome', headless: false, slowMo: 300 });
|
|
const context = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
|
const page = await context.newPage();
|
|
|
|
const BASE = 'http://localhost:5173';
|
|
let failed = false;
|
|
|
|
async function step(name, fn) {
|
|
process.stdout.write(`▶ ${name}... `);
|
|
try {
|
|
await fn();
|
|
console.log('✅');
|
|
} catch (e) {
|
|
console.log(`❌ ${e.message}`);
|
|
failed = true;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await step('Navigate to app → should redirect to /login', async () => {
|
|
await page.goto(BASE);
|
|
await page.waitForURL('**/login', { timeout: 5000 });
|
|
});
|
|
|
|
await step('Register a new account', async () => {
|
|
await page.click('text=Create account');
|
|
await page.fill('input[placeholder="Name"]', 'TestChef');
|
|
await page.fill('input[placeholder="Password"]', 'password123');
|
|
await page.fill('input[placeholder="Family code"]', 'dev-family-code');
|
|
await page.click('button[type="submit"]');
|
|
await page.waitForURL('**/lists', { timeout: 5000 });
|
|
});
|
|
|
|
await step('Create store "Kroger"', async () => {
|
|
await page.click('a:has-text("Stores")');
|
|
await page.waitForURL('**/stores');
|
|
await page.fill('input[placeholder="New store name"]', 'Kroger');
|
|
await page.click('button:has-text("Add")');
|
|
await page.waitForSelector('text=Kroger');
|
|
});
|
|
|
|
await step('Create store "Costco"', async () => {
|
|
await page.fill('input[placeholder="New store name"]', 'Costco');
|
|
await page.click('button:has-text("Add")');
|
|
await page.waitForSelector('text=Costco');
|
|
});
|
|
|
|
await step('Create shopping list "Weekly Groceries"', async () => {
|
|
await page.click('a:has-text("Lists")');
|
|
await page.waitForURL('**/lists');
|
|
await page.click('text=+ New list');
|
|
await page.fill('input[placeholder="List name"]', 'Weekly Groceries');
|
|
await page.selectOption('select', { label: 'Kroger' });
|
|
await page.click('button:has-text("Create")');
|
|
await page.waitForSelector('text=Weekly Groceries');
|
|
});
|
|
|
|
await step('Open list and add 5 items', async () => {
|
|
await page.click('text=Weekly Groceries');
|
|
await page.waitForURL(/\/lists\/\d+/);
|
|
|
|
for (const item of ['Milk', 'Eggs', 'Bread', 'Chicken breast', 'Broccoli']) {
|
|
await page.fill('input[placeholder="Add an item..."]', item);
|
|
await page.click('button:has-text("Add")');
|
|
await page.waitForSelector(`text=${item}`);
|
|
}
|
|
});
|
|
|
|
await step('Check off Milk and Eggs', async () => {
|
|
await page.click('button[aria-label="Check Milk"]');
|
|
await page.waitForSelector('text=Checked (1)');
|
|
await page.click('button[aria-label="Check Eggs"]');
|
|
await page.waitForSelector('text=Checked (2)');
|
|
});
|
|
|
|
await step('Uncheck Milk', async () => {
|
|
await page.click('button[aria-label="Uncheck Milk"]');
|
|
await page.waitForSelector('text=Checked (1)');
|
|
});
|
|
|
|
await step('Remove Broccoli', async () => {
|
|
await page.click('button[aria-label="Remove Broccoli"]');
|
|
await page.waitForFunction(() => !document.body.textContent.includes('Broccoli'), { timeout: 3000 });
|
|
});
|
|
|
|
await step('Create recipe "Chicken Stir Fry"', async () => {
|
|
await page.click('a:has-text("Recipes")');
|
|
await page.waitForURL('**/recipes');
|
|
await page.click('text=+ New recipe');
|
|
await page.waitForURL('**/recipes/new');
|
|
await page.fill('input[placeholder="Recipe title"]', 'Chicken Stir Fry');
|
|
await page.fill('textarea[placeholder*="description"]', 'Quick weeknight dinner');
|
|
|
|
const qtyInputs = page.locator('input[placeholder="Qty"]');
|
|
const nameInputs = page.locator('input[placeholder="Ingredient name"]');
|
|
await qtyInputs.nth(0).fill('2 lbs');
|
|
await nameInputs.nth(0).fill('chicken breast');
|
|
|
|
await page.click('text=+ Add ingredient');
|
|
await qtyInputs.nth(1).fill('1 head');
|
|
await nameInputs.nth(1).fill('broccoli');
|
|
|
|
await page.click('text=+ Add ingredient');
|
|
await qtyInputs.nth(2).fill('3 tbsp');
|
|
await nameInputs.nth(2).fill('soy sauce');
|
|
|
|
await page.fill('textarea[placeholder*="Instructions"]', '1. Cut chicken\n2. Stir fry\n3. Add sauce');
|
|
await page.click('button:has-text("Save Recipe")');
|
|
await page.waitForURL(/\/recipes\/\d+/);
|
|
});
|
|
|
|
await step('Add recipe ingredients to shopping list', async () => {
|
|
await page.click('button:has-text("Add to list")');
|
|
await page.waitForSelector('text=Choose a list');
|
|
await page.click('text=Weekly Groceries');
|
|
await page.waitForURL(/\/lists\/\d+/);
|
|
await page.waitForSelector('text=chicken breast');
|
|
});
|
|
|
|
await step('Verify list has recipe items', async () => {
|
|
const content = await page.textContent('body');
|
|
for (const item of ['2 lbs chicken breast', '1 head broccoli', '3 tbsp soy sauce']) {
|
|
if (!content.includes(item)) throw new Error(`Missing recipe item: ${item}`);
|
|
}
|
|
});
|
|
|
|
await step('Navigate back to lists overview', async () => {
|
|
await page.click('text=← Back');
|
|
await page.waitForURL('**/lists');
|
|
});
|
|
|
|
await step('Sign out', async () => {
|
|
await page.click('text=Sign out');
|
|
await page.waitForURL('**/login');
|
|
});
|
|
|
|
console.log('\n🎉 All tests passed!');
|
|
} catch (e) {
|
|
console.error(`\n💥 Test suite stopped at failure: ${e.message}`);
|
|
process.exitCode = 1;
|
|
} finally {
|
|
await new Promise(r => setTimeout(r, 2000));
|
|
await browser.close();
|
|
}
|