Add Vitest unit tests and Playwright e2e suite for frontend

Replaces the ad-hoc test-e2e.mjs scripts with a proper test stack:
Vitest covers $lib (api, auth, signalr) with mocked fetch/SignalR/navigation,
and @playwright/test covers auth, stores, lists, recipes, and SignalR realtime
sync between two browser contexts. Tests use uniqueName() for every entity
since the backend has no per-test reset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Josh Rogers
2026-05-06 22:17:55 -05:00
parent 76e8de9484
commit cde619e730
19 changed files with 2508 additions and 299 deletions
+7
View File
@@ -10,6 +10,13 @@ node_modules/
build/
.svelte-kit/
## Playwright
playwright-report/
test-results/
## Claude Code (local agent state)
.claude/
## Environment
.env
+1388 -2
View File
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -9,16 +9,25 @@
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@sveltejs/vite-plugin-svelte": "^7.1.1",
"@testing-library/svelte": "^5.3.1",
"@vitest/coverage-v8": "^4.1.5",
"jsdom": "^29.1.1",
"svelte": "^5.55.2",
"svelte-check": "^4.4.6",
"typescript": "^6.0.2",
"vite": "^8.0.7"
"vite": "^8.0.7",
"vitest": "^4.1.5"
},
"dependencies": {
"@microsoft/signalr": "^10.0.0",
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
reporter: 'html',
timeout: 30_000,
expect: {
timeout: 8_000
},
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry'
},
projects: [
{
name: 'chromium',
use: {
...devices['Pixel 5'],
viewport: { width: 390, height: 844 }
}
}
]
});
@@ -0,0 +1,12 @@
import { vi } from 'vitest';
export const goto = vi.fn();
export const invalidate = vi.fn();
export const invalidateAll = vi.fn();
export const preloadCode = vi.fn();
export const preloadData = vi.fn();
export const pushState = vi.fn();
export const replaceState = vi.fn();
export const beforeNavigate = vi.fn();
export const afterNavigate = vi.fn();
export const onNavigate = vi.fn();
+13
View File
@@ -0,0 +1,13 @@
export const page = {
params: {},
url: new URL('http://localhost/'),
route: { id: null },
status: 200,
error: null,
data: {},
form: null,
state: {}
};
export const navigating = null;
export const updated = { current: false, check: () => Promise.resolve(false) };
+1
View File
@@ -0,0 +1 @@
import '@testing-library/svelte';
+206
View File
@@ -0,0 +1,206 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock $app/navigation before importing api
vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
// Mock auth module
vi.mock('$lib/auth.svelte', () => ({
setLoggedIn: vi.fn()
}));
import { goto } from '$app/navigation';
import { setLoggedIn } from '$lib/auth.svelte';
import { getToken, setToken, clearToken, api } from '$lib/api';
const mockGoto = vi.mocked(goto);
const mockSetLoggedIn = vi.mocked(setLoggedIn);
beforeEach(() => {
localStorage.clear();
// Reset the module-level token cache by clearing storage before each test
vi.resetModules();
});
afterEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
describe('getToken', () => {
it('returns null when localStorage has no token', () => {
// The module caches token in a module-level variable, so we test
// the observable behavior: after clearToken, getToken returns null.
clearToken();
expect(getToken()).toBeNull();
});
it('returns the token stored via setToken', () => {
setToken('abc123');
expect(getToken()).toBe('abc123');
});
});
describe('setToken', () => {
it('persists token to localStorage', () => {
setToken('my-jwt');
expect(localStorage.getItem('token')).toBe('my-jwt');
});
it('calls setLoggedIn(true)', () => {
setToken('my-jwt');
expect(mockSetLoggedIn).toHaveBeenCalledWith(true);
});
});
describe('clearToken', () => {
it('removes token from localStorage', () => {
setToken('my-jwt');
clearToken();
expect(localStorage.getItem('token')).toBeNull();
});
it('makes getToken return null', () => {
setToken('my-jwt');
clearToken();
expect(getToken()).toBeNull();
});
it('calls setLoggedIn(false)', () => {
clearToken();
expect(mockSetLoggedIn).toHaveBeenCalledWith(false);
});
});
describe('api()', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
function makeFetchResponse(status: number, body?: unknown) {
const responseBody = body !== undefined ? JSON.stringify(body) : '';
return {
status,
ok: status >= 200 && status < 300,
json: vi.fn().mockResolvedValue(body ?? {}),
text: vi.fn().mockResolvedValue(responseBody)
};
}
it('attaches Authorization header when token is set', async () => {
setToken('tok-xyz');
const mockFetch = vi.mocked(fetch);
mockFetch.mockResolvedValueOnce(makeFetchResponse(200, { id: 1 }) as unknown as Response);
await api('/api/test');
expect(mockFetch).toHaveBeenCalledWith(
'/api/test',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer tok-xyz'
})
})
);
});
it('does not attach Authorization header when no token', async () => {
clearToken();
const mockFetch = vi.mocked(fetch);
mockFetch.mockResolvedValueOnce(makeFetchResponse(200, { id: 1 }) as unknown as Response);
await api('/api/test');
const calledHeaders = mockFetch.mock.calls[0][1]?.headers as Record<string, string>;
expect(calledHeaders?.Authorization).toBeUndefined();
});
it('returns parsed JSON on 200', async () => {
setToken('tok');
vi.mocked(fetch).mockResolvedValueOnce(
makeFetchResponse(200, { name: 'Kroger' }) as unknown as Response
);
const result = await api<{ name: string }>('/api/stores/1');
expect(result).toEqual({ name: 'Kroger' });
});
it('returns undefined on 204 No Content', async () => {
setToken('tok');
vi.mocked(fetch).mockResolvedValueOnce(makeFetchResponse(204) as unknown as Response);
const result = await api('/api/lists/1/items/5');
expect(result).toBeUndefined();
});
it('on 401 clears token, calls goto("/login"), and throws', async () => {
setToken('expired-tok');
vi.mocked(fetch).mockResolvedValueOnce(makeFetchResponse(401) as unknown as Response);
await expect(api('/api/protected')).rejects.toThrow('Unauthorized');
expect(getToken()).toBeNull();
expect(mockGoto).toHaveBeenCalledWith('/login');
});
it('on non-OK response throws error with body.error message', async () => {
setToken('tok');
vi.mocked(fetch).mockResolvedValueOnce(
makeFetchResponse(422, { error: 'Name already taken' }) as unknown as Response
);
await expect(api('/api/auth/register')).rejects.toThrow('Name already taken');
});
it('on non-OK response without body.error falls back to generic message', async () => {
setToken('tok');
const response = makeFetchResponse(500, {}) as unknown as Response;
vi.mocked(fetch).mockResolvedValueOnce(response);
await expect(api('/api/crash')).rejects.toThrow('Request failed: 500');
});
it('on non-OK response where JSON parse fails falls back to generic message', async () => {
setToken('tok');
const badResponse = {
status: 503,
ok: false,
json: vi.fn().mockRejectedValue(new SyntaxError('bad json')),
text: vi.fn().mockResolvedValue('')
};
vi.mocked(fetch).mockResolvedValueOnce(badResponse as unknown as Response);
await expect(api('/api/down')).rejects.toThrow('Request failed: 503');
});
it('sends Content-Type: application/json by default', async () => {
setToken('tok');
vi.mocked(fetch).mockResolvedValueOnce(
makeFetchResponse(200, {}) as unknown as Response
);
await api('/api/test');
const calledHeaders = vi.mocked(fetch).mock.calls[0][1]?.headers as Record<string, string>;
expect(calledHeaders['Content-Type']).toBe('application/json');
});
it('merges caller-provided headers with defaults', async () => {
setToken('tok');
vi.mocked(fetch).mockResolvedValueOnce(
makeFetchResponse(200, {}) as unknown as Response
);
await api('/api/test', { headers: { 'X-Custom': 'yes' } });
const calledHeaders = vi.mocked(fetch).mock.calls[0][1]?.headers as Record<string, string>;
expect(calledHeaders['X-Custom']).toBe('yes');
expect(calledHeaders['Content-Type']).toBe('application/json');
});
});
@@ -0,0 +1,61 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { initAuth, setLoggedIn, isLoggedIn } from '$lib/auth.svelte';
// This file must be named *.svelte.test.ts so the Svelte vite plugin
// compiles it with runes enabled (matches the *.svelte.ts source file).
beforeEach(() => {
localStorage.clear();
// Reset state to a known baseline before each test
setLoggedIn(false);
});
describe('initAuth()', () => {
it('sets logged-in state to true when a token exists in localStorage', () => {
localStorage.setItem('token', 'some-jwt');
initAuth();
expect(isLoggedIn()).toBe(true);
});
it('sets logged-in state to false when localStorage has no token', () => {
setLoggedIn(true); // start in logged-in state
localStorage.removeItem('token');
initAuth();
expect(isLoggedIn()).toBe(false);
});
it('treats an empty-string token as falsy (not logged in)', () => {
localStorage.setItem('token', '');
initAuth();
expect(isLoggedIn()).toBe(false);
});
});
describe('setLoggedIn()', () => {
it('sets state to true', () => {
setLoggedIn(true);
expect(isLoggedIn()).toBe(true);
});
it('sets state to false', () => {
setLoggedIn(true);
setLoggedIn(false);
expect(isLoggedIn()).toBe(false);
});
});
describe('isLoggedIn()', () => {
it('reflects current state without side-effects', () => {
setLoggedIn(true);
expect(isLoggedIn()).toBe(true);
setLoggedIn(false);
expect(isLoggedIn()).toBe(false);
});
});
+166
View File
@@ -0,0 +1,166 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// vi.hoisted() runs before vi.mock() factories, making these safe to reference.
const { mockStart, mockStop, mockBuild, mockWithAutomaticReconnect, mockWithUrl, mockStateContainer } =
vi.hoisted(() => {
const mockStateContainer = { value: 'Disconnected' };
const mockStop = vi.fn().mockResolvedValue(undefined);
const mockStart = vi.fn().mockResolvedValue(undefined);
const mockBuild = vi.fn();
const mockWithAutomaticReconnect = vi.fn();
const mockWithUrl = vi.fn();
return { mockStart, mockStop, mockBuild, mockWithAutomaticReconnect, mockStateContainer, mockWithUrl };
});
vi.mock('@microsoft/signalr', () => {
const mockConnection = {
get state() {
return mockStateContainer.value;
},
start: mockStart,
stop: mockStop,
on: vi.fn(),
off: vi.fn()
};
mockBuild.mockReturnValue(mockConnection);
mockWithAutomaticReconnect.mockReturnValue({ build: mockBuild });
class HubConnectionBuilder {
withUrl(url: string, opts: { accessTokenFactory?: () => string }) {
return mockWithUrl(url, opts);
}
}
return {
HubConnectionState: {
Disconnected: 'Disconnected',
Connected: 'Connected',
Connecting: 'Connecting',
Reconnecting: 'Reconnecting',
Disconnecting: 'Disconnecting'
},
HubConnectionBuilder
};
});
vi.mock('$lib/api', () => ({
getToken: vi.fn().mockReturnValue('test-token')
}));
import { getToken } from '$lib/api';
// Each test imports signalr fresh so the module-level refCount and connection
// singleton are reset between tests.
async function freshSignalr() {
vi.resetModules();
const mod = await import('$lib/signalr');
return mod;
}
let capturedAccessTokenFactory: (() => string) | null = null;
beforeEach(() => {
vi.clearAllMocks();
mockStateContainer.value = 'Disconnected';
capturedAccessTokenFactory = null;
mockStart.mockResolvedValue(undefined);
mockStop.mockResolvedValue(undefined);
vi.mocked(getToken).mockReturnValue('test-token');
mockWithUrl.mockImplementation((_url: string, opts: { accessTokenFactory?: () => string }) => {
capturedAccessTokenFactory = opts?.accessTokenFactory ?? null;
return { withAutomaticReconnect: mockWithAutomaticReconnect };
});
mockWithAutomaticReconnect.mockReturnValue({ build: mockBuild });
});
describe('startConnection()', () => {
it('starts the hub connection when disconnected', async () => {
const { startConnection } = await freshSignalr();
await startConnection();
expect(mockStart).toHaveBeenCalledOnce();
});
it('returns the connection object', async () => {
const { startConnection } = await freshSignalr();
const conn = await startConnection();
expect(conn.start).toBe(mockStart);
expect(conn.stop).toBe(mockStop);
});
it('passes getToken() via accessTokenFactory', async () => {
const { startConnection } = await freshSignalr();
await startConnection();
expect(capturedAccessTokenFactory).not.toBeNull();
expect(capturedAccessTokenFactory!()).toBe('test-token');
});
it('does not call start() again when connection is already active', async () => {
const { startConnection } = await freshSignalr();
await startConnection();
expect(mockStart).toHaveBeenCalledOnce();
mockStateContainer.value = 'Connected';
await startConnection();
expect(mockStart).toHaveBeenCalledOnce();
});
});
describe('stopConnection()', () => {
it('calls stop() when refcount reaches zero', async () => {
const { startConnection, stopConnection } = await freshSignalr();
await startConnection();
mockStateContainer.value = 'Connected';
await stopConnection();
expect(mockStop).toHaveBeenCalledOnce();
});
it('does not call stop() while more than one consumer holds a reference', async () => {
const { startConnection, stopConnection } = await freshSignalr();
await startConnection();
mockStateContainer.value = 'Connected';
await startConnection();
await stopConnection();
expect(mockStop).not.toHaveBeenCalled();
});
it('stops only after all consumers release', async () => {
const { startConnection, stopConnection } = await freshSignalr();
await startConnection();
mockStateContainer.value = 'Connected';
await startConnection();
await stopConnection();
expect(mockStop).not.toHaveBeenCalled();
await stopConnection();
expect(mockStop).toHaveBeenCalledOnce();
});
it('does not call stop() when connection is already disconnected', async () => {
const { startConnection, stopConnection } = await freshSignalr();
await startConnection();
// mockStateContainer.value stays 'Disconnected'
await stopConnection();
expect(mockStop).not.toHaveBeenCalled();
});
});
describe('accessTokenFactory', () => {
it('returns empty string when getToken() returns null', async () => {
vi.mocked(getToken).mockReturnValueOnce(null);
const { startConnection } = await freshSignalr();
await startConnection();
expect(capturedAccessTokenFactory).not.toBeNull();
expect(capturedAccessTokenFactory!()).toBe('');
});
});
+75
View File
@@ -0,0 +1,75 @@
import { test, expect } from '@playwright/test';
const FAMILY_CODE = 'dev-family-code';
function uniqueName(prefix: string) {
return `${prefix}-${Date.now()}`;
}
test.describe('Authentication', () => {
test('unauthenticated visit to app root redirects to /login', async ({ page }) => {
await page.goto('/');
await page.waitForURL('**/login');
await expect(page).toHaveURL(/\/login/);
});
test('registration with valid credentials lands on /lists', async ({ page }) => {
const name = uniqueName('Chef');
await page.goto('/');
await page.waitForURL('**/login');
await page.getByText('Create account').click();
await page.getByPlaceholder('Name').fill(name);
await page.getByPlaceholder('Password').fill('password123');
await page.getByPlaceholder('Family code').fill(FAMILY_CODE);
await page.getByRole('button', { name: 'Create account' }).click();
await page.waitForURL('**/lists');
await expect(page).toHaveURL(/\/lists/);
});
test('login with valid credentials lands on /lists', async ({ page }) => {
const name = uniqueName('LoginUser');
// Register first
await page.goto('/');
await page.waitForURL('**/login');
await page.getByText('Create account').click();
await page.getByPlaceholder('Name').fill(name);
await page.getByPlaceholder('Password').fill('password123');
await page.getByPlaceholder('Family code').fill(FAMILY_CODE);
await page.getByRole('button', { name: 'Create account' }).click();
await page.waitForURL('**/lists');
// Sign out
await page.getByText('Sign out').click();
await page.waitForURL('**/login');
// Log back in
await page.getByPlaceholder('Name').fill(name);
await page.getByPlaceholder('Password').fill('password123');
await page.getByRole('button', { name: /sign in|log in/i }).click();
await page.waitForURL('**/lists');
await expect(page).toHaveURL(/\/lists/);
});
test('sign out clears session and redirects to /login', async ({ page }) => {
const name = uniqueName('SignOutUser');
await page.goto('/');
await page.waitForURL('**/login');
await page.getByText('Create account').click();
await page.getByPlaceholder('Name').fill(name);
await page.getByPlaceholder('Password').fill('password123');
await page.getByPlaceholder('Family code').fill(FAMILY_CODE);
await page.getByRole('button', { name: 'Create account' }).click();
await page.waitForURL('**/lists');
await page.getByText('Sign out').click();
await page.waitForURL('**/login');
await expect(page).toHaveURL(/\/login/);
});
});
+18
View File
@@ -0,0 +1,18 @@
import type { Page } from '@playwright/test';
export const FAMILY_CODE = 'dev-family-code';
export function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
}
export async function registerAndLogin(page: Page, name: string): Promise<void> {
await page.goto('/');
await page.waitForURL('**/login');
await page.getByText('Create account').click();
await page.getByPlaceholder('Name').fill(name);
await page.getByPlaceholder('Password').fill('password123');
await page.getByPlaceholder('Family code').fill(FAMILY_CODE);
await page.getByRole('button', { name: 'Create account' }).click();
await page.waitForURL('**/lists');
}
+145
View File
@@ -0,0 +1,145 @@
import { test, expect } from '@playwright/test';
import { uniqueName, registerAndLogin } from './helpers';
test.describe('Shopping Lists', () => {
let storeName: string;
test.beforeEach(async ({ page }) => {
storeName = uniqueName('Kroger');
await registerAndLogin(page, uniqueName('ListUser'));
// Create a store so the list creation form has an option to select
await page.getByRole('link', { name: 'Stores' }).click();
await page.waitForURL('**/stores');
await page.getByPlaceholder('New store name').fill(storeName);
await page.getByRole('button', { name: 'Add' }).click();
await expect(page.getByText(storeName)).toBeVisible();
await page.getByRole('link', { name: 'Lists' }).click();
await page.waitForURL('**/lists');
});
test('displays the Shopping Lists heading', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Shopping Lists' })).toBeVisible();
});
test('creates a new list and shows it on the overview', async ({ page }) => {
const listName = uniqueName('Weekly Groceries');
await page.getByRole('button', { name: '+ New list' }).click();
await page.getByPlaceholder('List name').fill(listName);
await page.getByRole('combobox').selectOption({ label: storeName });
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText(listName)).toBeVisible();
});
test('opens a list detail page when clicked', async ({ page }) => {
const listName = uniqueName('Weekly Groceries');
await page.getByRole('button', { name: '+ New list' }).click();
await page.getByPlaceholder('List name').fill(listName);
await page.getByRole('combobox').selectOption({ label: storeName });
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText(listName)).toBeVisible();
await page.getByText(listName).click();
await page.waitForURL(/\/lists\/\d+/);
await expect(page.getByRole('heading', { name: listName })).toBeVisible();
});
test.describe('List detail', () => {
let listName: string;
test.beforeEach(async ({ page }) => {
listName = uniqueName('Weekly Groceries');
await page.getByRole('button', { name: '+ New list' }).click();
await page.getByPlaceholder('List name').fill(listName);
await page.getByRole('combobox').selectOption({ label: storeName });
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText(listName)).toBeVisible();
await page.getByText(listName).click();
await page.waitForURL(/\/lists\/\d+/);
});
test('shows empty state when list has no items', async ({ page }) => {
await expect(page.getByText('No items yet — add some above')).toBeVisible();
});
test('adds items to the list', async ({ page }) => {
const milk = uniqueName('Milk');
const eggs = uniqueName('Eggs');
const bread = uniqueName('Bread');
for (const item of [milk, eggs, bread]) {
await page.getByPlaceholder('Add an item...').fill(item);
await page.getByRole('button', { name: 'Add' }).click();
await expect(page.getByText(item)).toBeVisible();
}
});
test('checking an item moves it to the checked section', async ({ page }) => {
const milk = uniqueName('Milk');
await page.getByPlaceholder('Add an item...').fill(milk);
await page.getByRole('button', { name: 'Add' }).click();
await expect(page.getByText(milk)).toBeVisible();
await page.getByRole('button', { name: `Check ${milk}` }).click();
await expect(page.getByText('Checked (1)')).toBeVisible();
});
test('checking multiple items increments the checked count', async ({ page }) => {
const milk = uniqueName('Milk');
const eggs = uniqueName('Eggs');
for (const item of [milk, eggs]) {
await page.getByPlaceholder('Add an item...').fill(item);
await page.getByRole('button', { name: 'Add' }).click();
await expect(page.getByText(item)).toBeVisible();
}
await page.getByRole('button', { name: `Check ${milk}` }).click();
await expect(page.getByText('Checked (1)')).toBeVisible();
await page.getByRole('button', { name: `Check ${eggs}` }).click();
await expect(page.getByText('Checked (2)')).toBeVisible();
});
test('unchecking a checked item decrements the checked count', async ({ page }) => {
const milk = uniqueName('Milk');
await page.getByPlaceholder('Add an item...').fill(milk);
await page.getByRole('button', { name: 'Add' }).click();
await expect(page.getByText(milk)).toBeVisible();
await page.getByRole('button', { name: `Check ${milk}` }).click();
await expect(page.getByText('Checked (1)')).toBeVisible();
await page.getByRole('button', { name: `Uncheck ${milk}` }).click();
await expect(page.getByText('Checked (1)')).not.toBeVisible();
});
test('removing an item removes it from the list', async ({ page }) => {
const broccoli = uniqueName('Broccoli');
await page.getByPlaceholder('Add an item...').fill(broccoli);
await page.getByRole('button', { name: 'Add' }).click();
await expect(page.getByText(broccoli)).toBeVisible();
await page.getByRole('button', { name: `Remove ${broccoli}` }).click();
await expect(page.getByText(broccoli)).not.toBeVisible();
});
test('back button returns to lists overview', async ({ page }) => {
await page.getByText('← Back').click();
await page.waitForURL('**/lists');
await expect(page).toHaveURL(/\/lists$/);
});
});
});
@@ -0,0 +1,97 @@
import { test, expect, chromium } from '@playwright/test';
import { uniqueName, registerAndLogin, FAMILY_CODE } from './helpers';
// This spec uses two independent browser contexts to verify that SignalR
// pushes list mutations from one user to another in real time.
// It creates its own browser instance so both contexts can run in the same test.
test('real-time sync: two users see each other\'s list changes live', async () => {
const browser = await chromium.launch();
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 nameA = uniqueName('Mom');
const nameB = uniqueName('Dad');
const storeName = uniqueName('Kroger');
const listName = uniqueName('Weekly Groceries');
try {
// Register both users
await registerAndLogin(pageA, nameA);
await registerAndLogin(pageB, nameB);
// Mom creates a store
await pageA.getByRole('link', { name: 'Stores' }).click();
await pageA.waitForURL('**/stores');
await pageA.getByPlaceholder('New store name').fill(storeName);
await pageA.getByRole('button', { name: 'Add' }).click();
await expect(pageA.getByText(storeName)).toBeVisible();
// Both navigate to lists overview
await pageA.getByRole('link', { name: 'Lists' }).click();
await pageA.waitForURL('**/lists');
// Dad is also on the lists overview
await pageB.getByRole('link', { name: 'Lists' }).click();
await pageB.waitForURL('**/lists');
// Mom creates a list — Dad should see it appear via SignalR without navigating
await pageA.getByRole('button', { name: '+ New list' }).click();
await pageA.getByPlaceholder('List name').fill(listName);
await pageA.getByRole('combobox').selectOption({ label: storeName });
await pageA.getByRole('button', { name: 'Create' }).click();
await expect(pageA.getByText(listName)).toBeVisible();
await expect(pageB.getByText(listName)).toBeVisible({ timeout: 8000 });
// Both open the list
await pageA.getByText(listName).click();
await pageA.waitForURL(/\/lists\/\d+/);
await pageB.getByText(listName).click();
await pageB.waitForURL(/\/lists\/\d+/);
// Mom adds items — both users see each one appear via SignalR
for (const item of ['Milk', 'Eggs', 'Bread']) {
await pageA.getByPlaceholder('Add an item...').fill(item);
await pageA.getByRole('button', { name: 'Add' }).click();
// Both pages receive the update via SignalR (the adding user's own
// view also updates via the hub broadcast, not the API response)
await expect(pageA.getByText(item)).toBeVisible({ timeout: 8000 });
await expect(pageB.getByText(item)).toBeVisible({ timeout: 8000 });
}
// Dad checks Milk — Mom sees Checked (1)
await pageB.getByRole('button', { name: 'Check Milk' }).click();
await expect(pageB.getByText('Checked (1)')).toBeVisible();
await expect(pageA.getByText('Checked (1)')).toBeVisible({ timeout: 8000 });
// Mom checks Eggs — Dad sees Checked (2)
await pageA.getByRole('button', { name: 'Check Eggs' }).click();
await expect(pageA.getByText('Checked (2)')).toBeVisible();
await expect(pageB.getByText('Checked (2)')).toBeVisible({ timeout: 8000 });
// Dad unchecks Milk — Mom sees count drop to 1
await pageB.getByRole('button', { name: 'Uncheck Milk' }).click();
await expect(pageB.getByText('Checked (1)')).toBeVisible();
await expect(pageA.getByText('Checked (1)')).toBeVisible({ timeout: 8000 });
// Dad adds an item — Mom sees it
await pageB.getByPlaceholder('Add an item...').fill('Butter');
await pageB.getByRole('button', { name: 'Add' }).click();
await expect(pageB.getByText('Butter')).toBeVisible();
await expect(pageA.getByText('Butter')).toBeVisible({ timeout: 8000 });
// Mom removes Bread — Dad sees it disappear
await pageA.getByRole('button', { name: 'Remove Bread' }).click();
await expect(pageA.getByText('Bread')).not.toBeVisible();
await expect(pageB.getByText('Bread')).not.toBeVisible({ timeout: 8000 });
} finally {
await contextA.close();
await contextB.close();
await browser.close();
}
});
+161
View File
@@ -0,0 +1,161 @@
import { test, expect } from '@playwright/test';
import { uniqueName, registerAndLogin } from './helpers';
test.describe('Recipes and add-to-list flow', () => {
test.beforeEach(async ({ page }) => {
await registerAndLogin(page, uniqueName('RecipeUser'));
});
test('displays the Recipes heading', async ({ page }) => {
await page.getByRole('link', { name: 'Recipes' }).click();
await page.waitForURL('**/recipes');
await expect(page.getByRole('heading', { name: 'Recipes' })).toBeVisible();
});
test('creates a recipe and lands on the detail page', async ({ page }) => {
const recipeName = uniqueName('Chicken Stir Fry');
await page.getByRole('link', { name: 'Recipes' }).click();
await page.waitForURL('**/recipes');
await page.getByRole('link', { name: '+ New recipe' }).click();
await page.waitForURL('**/recipes/new');
await page.getByPlaceholder('Recipe title').fill(recipeName);
await page.getByPlaceholder('Short description (optional)').fill('Quick weeknight dinner');
const qtyInputs = page.getByPlaceholder('Qty');
const nameInputs = page.getByPlaceholder('Ingredient name');
await qtyInputs.nth(0).fill('2 lbs');
await nameInputs.nth(0).fill('chicken breast');
await page.getByRole('button', { name: '+ Add ingredient' }).click();
await qtyInputs.nth(1).fill('1 head');
await nameInputs.nth(1).fill('broccoli');
await page.getByRole('button', { name: '+ Add ingredient' }).click();
await qtyInputs.nth(2).fill('3 tbsp');
await nameInputs.nth(2).fill('soy sauce');
await page.getByPlaceholder('Instructions (optional)').fill('1. Cut chicken\n2. Stir fry\n3. Add sauce');
await page.getByRole('button', { name: 'Save Recipe' }).click();
await page.waitForURL(/\/recipes\/\d+/);
await expect(page.getByRole('heading', { name: recipeName })).toBeVisible();
await expect(page.getByText('Quick weeknight dinner')).toBeVisible();
await expect(page.getByText('chicken breast')).toBeVisible();
await expect(page.getByText('broccoli')).toBeVisible();
await expect(page.getByText('soy sauce')).toBeVisible();
});
test('recipe appears on the recipes list after creation', async ({ page }) => {
const recipeName = uniqueName('Pasta Primavera');
await page.getByRole('link', { name: 'Recipes' }).click();
await page.waitForURL('**/recipes');
await page.getByRole('link', { name: '+ New recipe' }).click();
await page.getByPlaceholder('Recipe title').fill(recipeName);
await page.getByRole('button', { name: 'Save Recipe' }).click();
await page.waitForURL(/\/recipes\/\d+/);
await page.getByText('← Back').click();
await page.waitForURL('**/recipes');
await expect(page.getByText(recipeName)).toBeVisible();
});
test('adds recipe ingredients to a shopping list and redirects to that list', async ({ page }) => {
const storeName = uniqueName('Kroger');
const listName = uniqueName('Weekly Groceries');
const recipeName = uniqueName('Chicken Stir Fry');
// Set up: create a store and a list
await page.getByRole('link', { name: 'Stores' }).click();
await page.waitForURL('**/stores');
await page.getByPlaceholder('New store name').fill(storeName);
await page.getByRole('button', { name: 'Add' }).click();
await expect(page.getByText(storeName)).toBeVisible();
await page.getByRole('link', { name: 'Lists' }).click();
await page.waitForURL('**/lists');
await page.getByRole('button', { name: '+ New list' }).click();
await page.getByPlaceholder('List name').fill(listName);
await page.getByRole('combobox').selectOption({ label: storeName });
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText(listName)).toBeVisible();
// Create a recipe
await page.getByRole('link', { name: 'Recipes' }).click();
await page.waitForURL('**/recipes');
await page.getByRole('link', { name: '+ New recipe' }).click();
await page.getByPlaceholder('Recipe title').fill(recipeName);
const qtyInputs = page.getByPlaceholder('Qty');
const nameInputs = page.getByPlaceholder('Ingredient name');
await qtyInputs.nth(0).fill('2 lbs');
await nameInputs.nth(0).fill('chicken breast');
await page.getByRole('button', { name: '+ Add ingredient' }).click();
await qtyInputs.nth(1).fill('1 head');
await nameInputs.nth(1).fill('broccoli');
await page.getByRole('button', { name: '+ Add ingredient' }).click();
await qtyInputs.nth(2).fill('3 tbsp');
await nameInputs.nth(2).fill('soy sauce');
await page.getByRole('button', { name: 'Save Recipe' }).click();
await page.waitForURL(/\/recipes\/\d+/);
// Add ingredients to list
await page.getByRole('button', { name: 'Add to list' }).click();
await expect(page.getByText('Choose a list:')).toBeVisible();
await page.getByRole('button', { name: new RegExp(listName) }).click();
await page.waitForURL(/\/lists\/\d+/);
const body = page.locator('body');
await expect(body).toContainText('2 lbs');
await expect(body).toContainText('chicken breast');
await expect(body).toContainText('1 head');
await expect(body).toContainText('broccoli');
await expect(body).toContainText('3 tbsp');
await expect(body).toContainText('soy sauce');
});
test('recipe ingredients are labeled with their source recipe on the list', async ({ page }) => {
const storeName = uniqueName('Kroger');
const listName = uniqueName('Weekly Groceries');
const recipeName = uniqueName('Chicken Stir Fry');
// Set up store and list
await page.getByRole('link', { name: 'Stores' }).click();
await page.waitForURL('**/stores');
await page.getByPlaceholder('New store name').fill(storeName);
await page.getByRole('button', { name: 'Add' }).click();
await expect(page.getByText(storeName)).toBeVisible();
await page.getByRole('link', { name: 'Lists' }).click();
await page.waitForURL('**/lists');
await page.getByRole('button', { name: '+ New list' }).click();
await page.getByPlaceholder('List name').fill(listName);
await page.getByRole('combobox').selectOption({ label: storeName });
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText(listName)).toBeVisible();
// Create recipe and add to list
await page.getByRole('link', { name: 'Recipes' }).click();
await page.waitForURL('**/recipes');
await page.getByRole('link', { name: '+ New recipe' }).click();
await page.getByPlaceholder('Recipe title').fill(recipeName);
await page.getByPlaceholder('Ingredient name').fill('chicken breast');
await page.getByRole('button', { name: 'Save Recipe' }).click();
await page.waitForURL(/\/recipes\/\d+/);
await page.getByRole('button', { name: 'Add to list' }).click();
await page.getByRole('button', { name: new RegExp(listName) }).click();
await page.waitForURL(/\/lists\/\d+/);
await expect(page.getByText(`from ${recipeName}`)).toBeVisible();
});
});
+92
View File
@@ -0,0 +1,92 @@
import { test, expect } from '@playwright/test';
import { uniqueName, registerAndLogin } from './helpers';
test.describe('Stores', () => {
test.beforeEach(async ({ page }) => {
await registerAndLogin(page, uniqueName('StoreUser'));
await page.getByRole('link', { name: 'Stores' }).click();
await page.waitForURL('**/stores');
});
test('displays the Stores heading', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Stores' })).toBeVisible();
});
test('adds a new store and shows it in the list', async ({ page }) => {
const storeName = uniqueName('Kroger');
await page.getByPlaceholder('New store name').fill(storeName);
await page.getByRole('button', { name: 'Add' }).click();
await expect(page.getByText(storeName)).toBeVisible();
});
test('adds multiple stores and shows them all', async ({ page }) => {
const kroger = uniqueName('Kroger');
const costco = uniqueName('Costco');
await page.getByPlaceholder('New store name').fill(kroger);
await page.getByRole('button', { name: 'Add' }).click();
await expect(page.getByText(kroger)).toBeVisible();
await page.getByPlaceholder('New store name').fill(costco);
await page.getByRole('button', { name: 'Add' }).click();
await expect(page.getByText(costco)).toBeVisible();
await expect(page.getByText(kroger)).toBeVisible();
});
test('edit inline renames the store', async ({ page }) => {
const storeName = uniqueName('Target');
const renamedName = uniqueName('Target-Express');
await page.getByPlaceholder('New store name').fill(storeName);
await page.getByRole('button', { name: 'Add' }).click();
await expect(page.getByText(storeName)).toBeVisible();
// Click Edit on the specific store row; after clicking, that row enters
// edit mode and its text content changes (the name is now in an input
// value, not a text node), so we re-query for the in-progress edit form
// rather than filtering by text again.
const storeRow = page.locator('li').filter({ hasText: storeName });
await storeRow.getByRole('button', { name: 'Edit' }).click();
// Only one row is in edit mode at a time — find the active edit form
const editForm = page.locator('li form');
await editForm.locator('input').fill(renamedName);
await editForm.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText(renamedName)).toBeVisible();
await expect(page.getByText(storeName, { exact: true })).not.toBeVisible();
});
test('cancel edit restores the original name', async ({ page }) => {
const storeName = uniqueName('Safeway');
await page.getByPlaceholder('New store name').fill(storeName);
await page.getByRole('button', { name: 'Add' }).click();
await expect(page.getByText(storeName)).toBeVisible();
const storeRow = page.locator('li').filter({ hasText: storeName });
await storeRow.getByRole('button', { name: 'Edit' }).click();
// Only one row is in edit mode at a time
const editForm = page.locator('li form');
await editForm.getByRole('button', { name: 'Cancel' }).click();
await expect(page.getByText(storeName)).toBeVisible();
});
test('delete removes the store from the list', async ({ page }) => {
const storeName = uniqueName('Aldi');
await page.getByPlaceholder('New store name').fill(storeName);
await page.getByRole('button', { name: 'Add' }).click();
await expect(page.getByText(storeName)).toBeVisible();
const storeRow = page.locator('li').filter({ hasText: storeName });
await storeRow.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText(storeName)).not.toBeVisible();
});
});
+28
View File
@@ -0,0 +1,28 @@
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { defineConfig } from 'vitest/config';
import { svelteTesting } from '@testing-library/svelte/vite';
import path from 'path';
export default defineConfig({
plugins: [
svelte({
compilerOptions: {
runes: true
}
}),
svelteTesting()
],
resolve: {
alias: {
$lib: path.resolve(__dirname, 'src/lib'),
'$app/navigation': path.resolve(__dirname, 'src/tests/mocks/app-navigation.ts'),
'$app/state': path.resolve(__dirname, 'src/tests/mocks/app-state.ts')
}
},
test: {
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{ts,svelte.ts}', 'src/**/*.svelte.test.ts'],
globals: true,
setupFiles: ['src/tests/setup.ts']
}
});
-147
View File
@@ -1,147 +0,0 @@
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();
}
-147
View File
@@ -1,147 +0,0 @@
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();
}