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/
|
build/
|
||||||
.svelte-kit/
|
.svelte-kit/
|
||||||
|
|
||||||
|
## Playwright
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
|
||||||
|
## Claude Code (local agent state)
|
||||||
|
.claude/
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
|||||||
Generated
+1388
-2
File diff suppressed because it is too large
Load Diff
@@ -9,16 +9,25 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"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": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
"@sveltejs/kit": "^2.57.0",
|
"@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": "^5.55.2",
|
||||||
"svelte-check": "^4.4.6",
|
"svelte-check": "^4.4.6",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^8.0.7"
|
"vite": "^8.0.7",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@microsoft/signalr": "^10.0.0",
|
"@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