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.5 KiB
JavaScript
148 lines
5.5 KiB
JavaScript
import { chromium } from 'playwright';
|
|
|
|
const browser = await chromium.launch({ channel: 'chrome', headless: false, slowMo: 200 });
|
|
|
|
const contextA = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
|
const contextB = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
|
const pageA = await contextA.newPage();
|
|
const pageB = await contextB.newPage();
|
|
|
|
const BASE = 'http://localhost:5173';
|
|
|
|
console.log('⏳ Arrange the two browser windows side by side. Starting in 8 seconds...');
|
|
await new Promise(r => setTimeout(r, 8000));
|
|
|
|
async function step(name, fn) {
|
|
process.stdout.write(`▶ ${name}... `);
|
|
try {
|
|
await fn();
|
|
console.log('✅');
|
|
} catch (e) {
|
|
console.log(`❌ ${e.message}`);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function register(page, name) {
|
|
await page.goto(BASE);
|
|
await page.waitForURL('**/login', { timeout: 5000 });
|
|
await page.click('text=Create account');
|
|
await page.fill('input[placeholder="Name"]', name);
|
|
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 });
|
|
}
|
|
|
|
try {
|
|
await step('Register User A (Mom)', async () => {
|
|
await register(pageA, 'Mom');
|
|
});
|
|
|
|
await step('Register User B (Dad)', async () => {
|
|
await register(pageB, 'Dad');
|
|
});
|
|
|
|
// Mom creates a store
|
|
await step('Mom creates store "Kroger"', async () => {
|
|
await pageA.click('a:has-text("Stores")');
|
|
await pageA.waitForURL('**/stores');
|
|
await pageA.fill('input[placeholder="New store name"]', 'Kroger');
|
|
await pageA.click('button:has-text("Add")');
|
|
await pageA.waitForSelector('text=Kroger');
|
|
});
|
|
|
|
// Both on lists overview — Mom creates a list, Dad should see it appear via SignalR
|
|
await step('Mom navigates to Lists', async () => {
|
|
await pageA.click('a:has-text("Lists")');
|
|
await pageA.waitForURL('**/lists');
|
|
});
|
|
|
|
await step('Mom creates list → Dad sees it appear on overview', async () => {
|
|
await pageA.click('text=+ New list');
|
|
await pageA.fill('input[placeholder="List name"]', 'Weekly Groceries');
|
|
await pageA.selectOption('select', { label: 'Kroger' });
|
|
await pageA.click('button:has-text("Create")');
|
|
await pageA.waitForSelector('text=Weekly Groceries');
|
|
// Dad should see it appear without navigating away
|
|
await pageB.waitForSelector('text=Weekly Groceries', { timeout: 5000 });
|
|
});
|
|
|
|
// Both open the list
|
|
await step('Mom opens the list', async () => {
|
|
await pageA.click('text=Weekly Groceries');
|
|
await pageA.waitForURL(/\/lists\/\d+/);
|
|
});
|
|
|
|
await step('Dad opens the same list', async () => {
|
|
await pageB.click('text=Weekly Groceries');
|
|
await pageB.waitForURL(/\/lists\/\d+/);
|
|
});
|
|
|
|
// Mom adds items — Dad should see them appear in real-time
|
|
await step('Mom adds "Milk" → Dad sees it appear', async () => {
|
|
await pageA.fill('input[placeholder="Add an item..."]', 'Milk');
|
|
await pageA.click('button:has-text("Add")');
|
|
await pageA.waitForSelector('text=Milk');
|
|
await pageB.waitForSelector('text=Milk', { timeout: 5000 });
|
|
});
|
|
|
|
await step('Mom adds "Eggs" → Dad sees it appear', async () => {
|
|
await pageA.fill('input[placeholder="Add an item..."]', 'Eggs');
|
|
await pageA.click('button:has-text("Add")');
|
|
await pageA.waitForSelector('text=Eggs');
|
|
await pageB.waitForSelector('text=Eggs', { timeout: 5000 });
|
|
});
|
|
|
|
await step('Mom adds "Bread" → Dad sees it appear', async () => {
|
|
await pageA.fill('input[placeholder="Add an item..."]', 'Bread');
|
|
await pageA.click('button:has-text("Add")');
|
|
await pageA.waitForSelector('text=Bread');
|
|
await pageB.waitForSelector('text=Bread', { timeout: 5000 });
|
|
});
|
|
|
|
// Dad checks off Milk — Mom should see it checked
|
|
await step('Dad checks "Milk" → Mom sees it checked', async () => {
|
|
await pageB.click('button[aria-label="Check Milk"]');
|
|
await pageB.waitForSelector('text=Checked (1)');
|
|
await pageA.waitForSelector('text=Checked (1)', { timeout: 5000 });
|
|
});
|
|
|
|
// Mom checks off Eggs — Dad should see it
|
|
await step('Mom checks "Eggs" → Dad sees it checked', async () => {
|
|
await pageA.click('button[aria-label="Check Eggs"]');
|
|
await pageA.waitForSelector('text=Checked (2)');
|
|
await pageB.waitForSelector('text=Checked (2)', { timeout: 5000 });
|
|
});
|
|
|
|
// Dad unchecks Milk — Mom should see it unchecked
|
|
await step('Dad unchecks "Milk" → Mom sees it unchecked', async () => {
|
|
await pageB.click('button[aria-label="Uncheck Milk"]');
|
|
await pageB.waitForSelector('text=Checked (1)');
|
|
await pageA.waitForSelector('text=Checked (1)', { timeout: 5000 });
|
|
});
|
|
|
|
// Dad adds an item — Mom should see it
|
|
await step('Dad adds "Butter" → Mom sees it appear', async () => {
|
|
await pageB.fill('input[placeholder="Add an item..."]', 'Butter');
|
|
await pageB.click('button:has-text("Add")');
|
|
await pageB.waitForSelector('text=Butter');
|
|
await pageA.waitForSelector('text=Butter', { timeout: 5000 });
|
|
});
|
|
|
|
// Mom removes Bread — Dad should see it disappear
|
|
await step('Mom removes "Bread" → Dad sees it disappear', async () => {
|
|
await pageA.click('button[aria-label="Remove Bread"]');
|
|
await pageA.waitForFunction(() => !document.body.textContent.includes('Bread'), { timeout: 3000 });
|
|
await pageB.waitForFunction(() => !document.body.textContent.includes('Bread'), { timeout: 5000 });
|
|
});
|
|
|
|
console.log('\n🎉 All multi-user tests passed! Real-time sync works.');
|
|
} catch (e) {
|
|
console.error(`\n💥 Test failed: ${e.message}`);
|
|
process.exitCode = 1;
|
|
} finally {
|
|
await new Promise(r => setTimeout(r, 2000));
|
|
await browser.close();
|
|
}
|