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:
@@ -10,6 +10,13 @@ node_modules/
|
||||
build/
|
||||
.svelte-kit/
|
||||
|
||||
## Playwright
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
## Claude Code (local agent state)
|
||||
.claude/
|
||||
|
||||
## Environment
|
||||
.env
|
||||
|
||||
|
||||
Generated
+1388
-2
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
@@ -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) };
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/svelte';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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']
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user