diff --git a/frontend/e2e/items.spec.ts b/frontend/e2e/items.spec.ts
new file mode 100644
index 0000000..227b1ae
--- /dev/null
+++ b/frontend/e2e/items.spec.ts
@@ -0,0 +1,57 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Items Page', () => {
+ test('renders the page heading', async ({ page }) => {
+ await page.goto('/items');
+
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText('Items', {
+ timeout: 10_000,
+ });
+ });
+
+ test('displays the items table with data from the API', async ({ page }) => {
+ await page.goto('/items');
+
+ // Wait for loading spinner to disappear, indicating data has loaded
+ await expect(page.getByRole('progressbar')).toBeVisible({ timeout: 10_000 });
+ await expect(page.getByRole('progressbar')).not.toBeVisible({ timeout: 10_000 });
+
+ // Table should be visible with expected column headers
+ await expect(page.getByRole('table')).toBeVisible({ timeout: 10_000 });
+ await expect(page.getByRole('columnheader', { name: 'Name' })).toBeVisible();
+ await expect(page.getByRole('columnheader', { name: 'Price' })).toBeVisible();
+ });
+
+ test('navigates to Items page via nav link and back', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText(
+ 'Welcome to the Full Stack Application',
+ { timeout: 10_000 }
+ );
+
+ await page.getByRole('link', { name: /items/i }).click();
+ await expect(page).toHaveURL('/items');
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText('Items', {
+ timeout: 10_000,
+ });
+
+ await page.getByRole('link', { name: /home/i }).click();
+ await expect(page).toHaveURL('/');
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText(
+ 'Welcome to the Full Stack Application'
+ );
+ });
+
+ test('renders table column headers', async ({ page }) => {
+ await page.goto('/items');
+
+ await expect(page.getByRole('columnheader', { name: 'ID' })).toBeVisible({
+ timeout: 10_000,
+ });
+ await expect(page.getByRole('columnheader', { name: 'Name' })).toBeVisible();
+ await expect(page.getByRole('columnheader', { name: 'Price' })).toBeVisible();
+ await expect(page.getByRole('columnheader', { name: 'Description' })).toBeVisible();
+ await expect(page.getByRole('columnheader', { name: 'Created At' })).toBeVisible();
+ await expect(page.getByRole('columnheader', { name: 'Updated At' })).toBeVisible();
+ });
+});
diff --git a/frontend/e2e/navigation.spec.ts b/frontend/e2e/navigation.spec.ts
index 2ea047c..a1ab499 100644
--- a/frontend/e2e/navigation.spec.ts
+++ b/frontend/e2e/navigation.spec.ts
@@ -32,6 +32,20 @@ test.describe('Navigation', () => {
await expect(page.getByText('Full Stack App', { exact: true })).toBeVisible();
await expect(page.getByRole('link', { name: /home/i })).toBeVisible();
await expect(page.getByRole('link', { name: /health/i })).toBeVisible();
+ await expect(page.getByRole('link', { name: /items/i })).toBeVisible();
+ });
+
+ test('navigates from Home to Items via nav link', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText(
+ 'Welcome to the Full Stack Application'
+ );
+
+ await page.getByRole('link', { name: /items/i }).click();
+ await expect(page).toHaveURL('/items');
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText('Items', {
+ timeout: 10_000,
+ });
});
test('renders the footer with current year', async ({ page }) => {
diff --git a/frontend/e2e/websocket.spec.ts b/frontend/e2e/websocket.spec.ts
new file mode 100644
index 0000000..59d5ab8
--- /dev/null
+++ b/frontend/e2e/websocket.spec.ts
@@ -0,0 +1,79 @@
+import { test, expect } from '@playwright/test';
+
+/**
+ * WebSocket integration e2e tests.
+ *
+ * These tests verify that the app remains fully functional regardless of the
+ * WebSocket connection state. The WebSocket provider connects in the background,
+ * so we assert on observable UI behaviour rather than on the socket itself.
+ */
+test.describe('WebSocket Integration', () => {
+ test('app loads and renders correctly with WebSocket running in background', async ({ page }) => {
+ const wsErrors: string[] = [];
+ page.on('pageerror', (error) => {
+ if (
+ error.message.toLowerCase().includes('websocket') ||
+ error.message.includes('ws://')
+ ) {
+ wsErrors.push(error.message);
+ }
+ });
+
+ await page.goto('/');
+
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText(
+ 'Welcome to the Full Stack Application',
+ { timeout: 10_000 }
+ );
+
+ // No uncaught WebSocket-related JS errors should surface to the page
+ expect(wsErrors).toHaveLength(0);
+ });
+
+ test('navigation works correctly while WebSocket is active', async ({ page }) => {
+ await page.goto('/');
+ await expect(
+ page.getByRole('heading', { level: 1 })
+ ).toHaveText('Welcome to the Full Stack Application', { timeout: 10_000 });
+
+ // Navigate away and back — the WebSocket provider should survive route changes
+ await page.getByRole('link', { name: /health/i }).click();
+ await expect(page).toHaveURL('/health');
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText(
+ 'System Health',
+ { timeout: 10_000 }
+ );
+
+ await page.getByRole('link', { name: /home/i }).click();
+ await expect(page).toHaveURL('/');
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText(
+ 'Welcome to the Full Stack Application'
+ );
+ });
+
+ test('app recovers gracefully when WebSocket connection is interrupted', async ({
+ page,
+ context,
+ }) => {
+ await page.goto('/');
+ await expect(
+ page.getByRole('heading', { level: 1 })
+ ).toHaveText('Welcome to the Full Stack Application', { timeout: 10_000 });
+
+ // Simulate a network interruption by going offline then online
+ await context.setOffline(true);
+ await context.setOffline(false);
+
+ // App UI must still be functional after the interruption
+ await expect(
+ page.getByRole('link', { name: /health/i })
+ ).toBeVisible({ timeout: 10_000 });
+
+ await page.getByRole('link', { name: /health/i }).click();
+ await expect(page).toHaveURL('/health');
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText(
+ 'System Health',
+ { timeout: 10_000 }
+ );
+ });
+});
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 603e151..1a1d44c 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -21,6 +21,7 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.1",
+ "reconnecting-websocket": "^4.4.0",
"typescript": "^5.8.3",
"vite": "^6.3.5"
},
@@ -3126,6 +3127,12 @@
"react-dom": ">=16.6.0"
}
},
+ "node_modules/reconnecting-websocket": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz",
+ "integrity": "sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng==",
+ "license": "MIT"
+ },
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 04f311d..7c34fc4 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -31,6 +31,7 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.1",
+ "reconnecting-websocket": "^4.4.0",
"typescript": "^5.8.3",
"vite": "^6.3.5"
},
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 9d7e827..968674e 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -2,6 +2,7 @@ import { CssBaseline, ThemeProvider, createTheme } from '@mui/material';
import { BrowserRouter } from 'react-router-dom';
import AppRoutes from './routes';
import Layout from './components/Layout';
+import { WebSocketProvider } from './context/WebSocketContext';
const theme = createTheme({
palette: {
@@ -20,9 +21,11 @@ function App() {
-
-
-
+
+
+
+
+
);
diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx
index 8d6ed60..acdc5bc 100644
--- a/frontend/src/__tests__/App.test.tsx
+++ b/frontend/src/__tests__/App.test.tsx
@@ -2,12 +2,22 @@ import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import App from '../App';
-// Mock the health service to prevent actual API calls during render
+// Prevent WebSocketProvider from opening a real connection in jsdom
+vi.mock('reconnecting-websocket', () => ({
+ default: class MockRWS extends EventTarget {
+ close() {}
+ },
+}));
+
+// Mock the health and item services to prevent actual API calls during render
vi.mock('../api/client', () => ({
healthService: {
checkLiveness: vi.fn().mockReturnValue(new Promise(() => {})),
checkReadiness: vi.fn().mockReturnValue(new Promise(() => {})),
},
+ itemService: {
+ list: vi.fn().mockReturnValue(new Promise(() => {})),
+ },
}));
describe('App', () => {
@@ -16,6 +26,7 @@ describe('App', () => {
expect(screen.getByText('Full Stack App')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /home/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /health/i })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /items/i })).toBeInTheDocument();
});
it('renders the Home page by default', () => {
diff --git a/frontend/src/__tests__/WebSocketContext.test.tsx b/frontend/src/__tests__/WebSocketContext.test.tsx
new file mode 100644
index 0000000..80a5b19
--- /dev/null
+++ b/frontend/src/__tests__/WebSocketContext.test.tsx
@@ -0,0 +1,178 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import React from 'react';
+
+interface MockWSInstance extends EventTarget {
+ open: () => void;
+ close: () => void;
+ receive: (data: string) => void;
+}
+
+// vi.hoisted ensures the class is defined before vi.mock hoists the factory
+const { MockRWS, getInstance } = vi.hoisted(() => {
+ let _instance: MockWSInstance | null = null;
+
+ class MockRWS extends EventTarget implements MockWSInstance {
+ constructor() {
+ super();
+ _instance = this;
+ }
+ close() { this.dispatchEvent(new CloseEvent('close')); }
+ open() { this.dispatchEvent(new Event('open')); }
+ receive(data: string) { this.dispatchEvent(new MessageEvent('message', { data })); }
+ }
+
+ return { MockRWS, getInstance: (): MockWSInstance => _instance! };
+});
+
+vi.mock('reconnecting-websocket', () => ({ default: MockRWS }));
+
+import { WebSocketProvider, useWebSocketContext } from '../context/WebSocketContext';
+
+function wrapper({ children }: { children: React.ReactNode }) {
+ return React.createElement(WebSocketProvider, null, children);
+}
+
+describe('WebSocketContext', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ vi.restoreAllMocks();
+ });
+
+ it('throws when used outside provider', () => {
+ // Suppress expected console.error from React
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ expect(() => renderHook(() => useWebSocketContext())).toThrow(
+ 'useWebSocketContext must be used within '
+ );
+ consoleSpy.mockRestore();
+ });
+
+ it('starts in connecting status', () => {
+ const { result } = renderHook(() => useWebSocketContext(), { wrapper });
+ expect(result.current.connectionStatus).toBe('connecting');
+ });
+
+ it('updates connectionStatus to open when socket opens', () => {
+ const { result } = renderHook(() => useWebSocketContext(), { wrapper });
+ act(() => getInstance().open());
+ expect(result.current.connectionStatus).toBe('open');
+ });
+
+ it('exposes lastMessage after a message is received', () => {
+ const { result } = renderHook(() => useWebSocketContext(), { wrapper });
+ act(() =>
+ getInstance().receive('{"type":"item.created","payload":{"id":42}}')
+ );
+ expect(result.current.lastMessage).toEqual({
+ type: 'item.created',
+ payload: { id: 42 },
+ });
+ });
+
+ it('subscribe receives events of the subscribed type', () => {
+ const handler = vi.fn();
+ const { result } = renderHook(() => useWebSocketContext(), { wrapper });
+
+ act(() => {
+ result.current.subscribe('item.created', handler);
+ });
+
+ act(() =>
+ getInstance().receive('{"type":"item.created","payload":{"id":1}}')
+ );
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith({ type: 'item.created', payload: { id: 1 } });
+ });
+
+ it('subscribe does not fire for other message types', () => {
+ const handler = vi.fn();
+ const { result } = renderHook(() => useWebSocketContext(), { wrapper });
+
+ act(() => {
+ result.current.subscribe('item.created', handler);
+ });
+
+ act(() =>
+ getInstance().receive('{"type":"item.deleted","payload":{"id":1}}')
+ );
+
+ expect(handler).not.toHaveBeenCalled();
+ });
+
+ it('unsubscribe returned from subscribe deregisters the handler', () => {
+ const handler = vi.fn();
+ const { result } = renderHook(() => useWebSocketContext(), { wrapper });
+
+ let unsubscribe!: () => void;
+ act(() => {
+ unsubscribe = result.current.subscribe('item.updated', handler);
+ });
+
+ act(() => unsubscribe());
+
+ act(() =>
+ getInstance().receive('{"type":"item.updated","payload":{}}')
+ );
+
+ expect(handler).not.toHaveBeenCalled();
+ });
+
+ it('updates connectionStatus to closed when socket closes', () => {
+ const { result } = renderHook(() => useWebSocketContext(), { wrapper });
+ act(() => getInstance().open());
+ expect(result.current.connectionStatus).toBe('open');
+
+ act(() => getInstance().close());
+ expect(result.current.connectionStatus).toBe('closed');
+ });
+
+ it('ignores non-JSON frames without updating lastMessage', () => {
+ const { result } = renderHook(() => useWebSocketContext(), { wrapper });
+ act(() => getInstance().receive('not valid json at all'));
+ expect(result.current.lastMessage).toBeNull();
+ });
+
+ it('calls all subscribers when multiple handlers are registered for the same type', () => {
+ const handlerA = vi.fn();
+ const handlerB = vi.fn();
+ const { result } = renderHook(() => useWebSocketContext(), { wrapper });
+
+ act(() => {
+ result.current.subscribe('item.created', handlerA);
+ result.current.subscribe('item.created', handlerB);
+ });
+
+ act(() =>
+ getInstance().receive('{"type":"item.created","payload":{"id":2}}')
+ );
+
+ expect(handlerA).toHaveBeenCalledTimes(1);
+ expect(handlerB).toHaveBeenCalledTimes(1);
+ expect(handlerA).toHaveBeenCalledWith({ type: 'item.created', payload: { id: 2 } });
+ expect(handlerB).toHaveBeenCalledWith({ type: 'item.created', payload: { id: 2 } });
+ });
+
+ it('unsubscribe only removes the specific handler leaving others intact', () => {
+ const handlerA = vi.fn();
+ const handlerB = vi.fn();
+ const { result } = renderHook(() => useWebSocketContext(), { wrapper });
+
+ let unsubscribeA!: () => void;
+ act(() => {
+ unsubscribeA = result.current.subscribe('item.deleted', handlerA);
+ result.current.subscribe('item.deleted', handlerB);
+ });
+
+ act(() => unsubscribeA());
+
+ act(() =>
+ getInstance().receive('{"type":"item.deleted","payload":{"id":5}}')
+ );
+
+ expect(handlerA).not.toHaveBeenCalled();
+ expect(handlerB).toHaveBeenCalledTimes(1);
+ expect(handlerB).toHaveBeenCalledWith({ type: 'item.deleted', payload: { id: 5 } });
+ });
+});
diff --git a/frontend/src/__tests__/useWebSocket.test.ts b/frontend/src/__tests__/useWebSocket.test.ts
new file mode 100644
index 0000000..1740a05
--- /dev/null
+++ b/frontend/src/__tests__/useWebSocket.test.ts
@@ -0,0 +1,136 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+
+interface MockWSInstance extends EventTarget {
+ url: string;
+ sent: string[];
+ open: () => void;
+ close: () => void;
+ receive: (data: string) => void;
+ send: (data: string) => void;
+}
+
+// vi.hoisted ensures the class is defined before vi.mock hoists the factory
+const { MockRWS, getInstance } = vi.hoisted(() => {
+ let _instance: MockWSInstance | null = null;
+
+ class MockRWS extends EventTarget implements MockWSInstance {
+ url: string;
+ sent: string[] = [];
+ constructor(url: string) {
+ super();
+ _instance = this;
+ this.url = url;
+ }
+ send(data: string) { this.sent.push(data); }
+ close() { this.dispatchEvent(new CloseEvent('close')); }
+ open() { this.dispatchEvent(new Event('open')); }
+ receive(data: string) { this.dispatchEvent(new MessageEvent('message', { data })); }
+ }
+
+ return { MockRWS, getInstance: (): MockWSInstance => _instance! };
+});
+
+vi.mock('reconnecting-websocket', () => ({ default: MockRWS }));
+
+import { useWebSocket } from '../hooks/useWebSocket';
+
+describe('useWebSocket', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ vi.restoreAllMocks();
+ });
+
+ it('starts in connecting status', () => {
+ const { result } = renderHook(() => useWebSocket());
+ expect(result.current.connectionStatus).toBe('connecting');
+ });
+
+ it('updates status to open when socket opens', () => {
+ const { result } = renderHook(() => useWebSocket());
+ act(() => getInstance().open());
+ expect(result.current.connectionStatus).toBe('open');
+ });
+
+ it('updates status to closed when socket closes', () => {
+ const { result } = renderHook(() => useWebSocket());
+ act(() => getInstance().open());
+ act(() => getInstance().close());
+ expect(result.current.connectionStatus).toBe('closed');
+ });
+
+ it('parses and exposes lastMessage', () => {
+ const { result } = renderHook(() => useWebSocket());
+ act(() => getInstance().receive('{"type":"item.created","payload":{"id":1}}'));
+ expect(result.current.lastMessage).toEqual({ type: 'item.created', payload: { id: 1 } });
+ });
+
+ it('calls onMessage callback', () => {
+ const onMessage = vi.fn();
+ renderHook(() => useWebSocket({ onMessage }));
+ act(() => getInstance().receive('{"type":"item.updated","payload":{}}'));
+ expect(onMessage).toHaveBeenCalledWith({ type: 'item.updated', payload: {} });
+ });
+
+ it('ignores non-JSON frames', () => {
+ const onMessage = vi.fn();
+ const { result } = renderHook(() => useWebSocket({ onMessage }));
+ act(() => getInstance().receive('not-valid-json'));
+ expect(result.current.lastMessage).toBeNull();
+ expect(onMessage).not.toHaveBeenCalled();
+ });
+
+ it('sendMessage serialises and sends data', () => {
+ const { result } = renderHook(() => useWebSocket());
+ act(() => result.current.sendMessage({ hello: 'world' }));
+ expect(getInstance().sent).toContain('{"hello":"world"}');
+ });
+
+ it('closes the socket on unmount', () => {
+ const { unmount } = renderHook(() => useWebSocket());
+ const closeSpy = vi.spyOn(getInstance(), 'close');
+ unmount();
+ expect(closeSpy).toHaveBeenCalled();
+ });
+
+ it('uses the custom path option in the WebSocket URL', () => {
+ renderHook(() => useWebSocket({ path: '/ws/events' }));
+ expect(getInstance().url).toContain('/ws/events');
+ });
+
+ it('does not recreate the socket when onMessage callback changes between renders', () => {
+ const onMessage1 = vi.fn();
+ const onMessage2 = vi.fn();
+ const { rerender } = renderHook(
+ ({ cb }: { cb: (msg: { type: string; payload: unknown }) => void }) =>
+ useWebSocket({ onMessage: cb }),
+ { initialProps: { cb: onMessage1 } }
+ );
+ const firstInstance = getInstance();
+ const closeSpy = vi.spyOn(firstInstance, 'close');
+
+ rerender({ cb: onMessage2 });
+
+ // Socket must not be closed and re-created
+ expect(closeSpy).not.toHaveBeenCalled();
+ expect(getInstance()).toBe(firstInstance);
+
+ // And the NEW callback should be invoked (ref is up-to-date)
+ act(() => getInstance().receive('{"type":"item.updated","payload":{}}'));
+ expect(onMessage2).toHaveBeenCalledTimes(1);
+ expect(onMessage1).not.toHaveBeenCalled();
+ });
+
+ it('returns connectionStatus to open after a reconnect', () => {
+ const { result } = renderHook(() => useWebSocket());
+ act(() => getInstance().open());
+ expect(result.current.connectionStatus).toBe('open');
+
+ act(() => getInstance().close());
+ expect(result.current.connectionStatus).toBe('closed');
+
+ // Simulate ReconnectingWebSocket successfully re-establishing the connection
+ act(() => getInstance().open());
+ expect(result.current.connectionStatus).toBe('open');
+ });
+});
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 1362ed1..d55b4cd 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -12,6 +12,70 @@ api.interceptors.response.use(
}
);
+export interface Item {
+ id: number;
+ name: string;
+ price: number;
+ description?: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export const itemService = {
+ list: async (limit?: number, offset?: number): Promise- => {
+ try {
+ const params = new URLSearchParams();
+ if (limit !== undefined) params.set('limit', String(limit));
+ if (offset !== undefined) params.set('offset', String(offset));
+ const query = params.toString() ? `?${params.toString()}` : '';
+ const response = await api.get
- (`/api/v1/items${query}`);
+ return response.data;
+ } catch (error) {
+ console.error('Failed to fetch items:', error);
+ throw error;
+ }
+ },
+
+ get: async (id: number): Promise
- => {
+ try {
+ const response = await api.get
- (`/api/v1/items/${id}`);
+ return response.data;
+ } catch (error) {
+ console.error('Failed to fetch item:', error);
+ throw error;
+ }
+ },
+
+ create: async (item: Omit
- ): Promise
- => {
+ try {
+ const response = await api.post
- ('/api/v1/items', item);
+ return response.data;
+ } catch (error) {
+ console.error('Failed to create item:', error);
+ throw error;
+ }
+ },
+
+ update: async (id: number, item: Partial>): Promise
- => {
+ try {
+ const response = await api.put
- (`/api/v1/items/${id}`, item);
+ return response.data;
+ } catch (error) {
+ console.error('Failed to update item:', error);
+ throw error;
+ }
+ },
+
+ delete: async (id: number): Promise => {
+ try {
+ await api.delete(`/api/v1/items/${id}`);
+ } catch (error) {
+ console.error('Failed to delete item:', error);
+ throw error;
+ }
+ },
+};
+
export const healthService = {
checkLiveness: async () => {
try {
diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts
index 8692020..e87b2ae 100644
--- a/frontend/src/api/config.ts
+++ b/frontend/src/api/config.ts
@@ -13,3 +13,10 @@ export const axiosConfig = {
'Content-Type': 'application/json',
},
};
+
+// WebSocket base URL. In production, derived from the current page origin;
+// in development, points directly at the backend dev server.
+export const WS_BASE_URL =
+ process.env.NODE_ENV === 'production'
+ ? `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}`
+ : 'ws://localhost:8081';
diff --git a/frontend/src/components/Layout/__tests__/Layout.test.tsx b/frontend/src/components/Layout/__tests__/Layout.test.tsx
index 048b97a..f559394 100644
--- a/frontend/src/components/Layout/__tests__/Layout.test.tsx
+++ b/frontend/src/components/Layout/__tests__/Layout.test.tsx
@@ -17,10 +17,11 @@ describe('Layout', () => {
expect(screen.getByText('Full Stack App')).toBeInTheDocument();
});
- it('renders Home and Health nav links', () => {
+ it('renders Home, Health, and Items nav links', () => {
renderLayout();
expect(screen.getByRole('link', { name: /home/i })).toHaveAttribute('href', '/');
expect(screen.getByRole('link', { name: /health/i })).toHaveAttribute('href', '/health');
+ expect(screen.getByRole('link', { name: /items/i })).toHaveAttribute('href', '/items');
});
it('renders children content', () => {
diff --git a/frontend/src/components/Layout/index.tsx b/frontend/src/components/Layout/index.tsx
index c6dc6d0..c89e36d 100644
--- a/frontend/src/components/Layout/index.tsx
+++ b/frontend/src/components/Layout/index.tsx
@@ -20,6 +20,9 @@ const Layout: React.FC = ({ children }) => {
+
diff --git a/frontend/src/context/WebSocketContext.tsx b/frontend/src/context/WebSocketContext.tsx
new file mode 100644
index 0000000..f170c29
--- /dev/null
+++ b/frontend/src/context/WebSocketContext.tsx
@@ -0,0 +1,45 @@
+import React, { createContext, useContext, useCallback, useRef } from 'react';
+import { useWebSocket } from '../hooks/useWebSocket';
+import type { WebSocketMessage, ConnectionStatus } from '../hooks/useWebSocket';
+
+interface WebSocketContextValue {
+ lastMessage: WebSocketMessage | null;
+ connectionStatus: ConnectionStatus;
+ subscribe: (type: string, handler: (msg: WebSocketMessage) => void) => () => void;
+}
+
+const WebSocketContext = createContext(null);
+
+export function WebSocketProvider({ children }: { children: React.ReactNode }) {
+ const handlersRef = useRef