Files
YesChef/test-e2e-multiuser.mjs
T
Josh Rogers 48d30df07b Initial commit: YesChef family shopping list and recipe app
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>
2026-05-06 19:32:39 -05:00

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();
}