Skip to content

Commit eb049e7

Browse files
amingclawdevclaude
andcommitted
feat: comprehensive readiness check — wait for ALL services before UI
Loading overlay now checks /api/readiness which verifies ALL 4 services: - Express server (always true if endpoint responds) - WebSocket (frontRouteRegistered) - dbservice (port 30002 /health) - toolService (port 30004 /health) Previously only checked WebSocket which was ready before dbservice, causing premature UI interaction that crashed dbservice startup. Added APIManager.checkReadiness() and updated Layout to use it. Tests updated with mock for checkReadiness. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 681e225 commit eb049e7

4 files changed

Lines changed: 63 additions & 9 deletions

File tree

client/src/Layout/index.js

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,22 +55,21 @@ const Layout = ({ Child }) => {
5555

5656
const fetchFingerPrints = useFingerPrintStore((state)=>state.fetchFingerPrints);
5757

58-
// Backend readiness check — block UI until backend services are up
58+
// Backend readiness check — block UI until ALL backend services are up
5959
useEffect(() => {
6060
let cancelled = false;
61-
const api = APIManager.getInstance();
62-
const MAX_RETRIES = 30; // 30 x 1s = 30s max wait
61+
const MAX_RETRIES = 60; // 60 x 1s = 60s max wait
6362
let attempt = 0;
6463
const check = async () => {
6564
while (!cancelled && attempt < MAX_RETRIES) {
6665
attempt++;
6766
try {
68-
const res = await api.checkWebSocket();
69-
if (res && res.success !== false) {
67+
const res = await APIManager.getInstance().checkReadiness();
68+
if (res && res.success) {
7069
if (!cancelled) setBackendReady(true);
7170
return;
7271
}
73-
} catch (_) { /* retry */ }
72+
} catch (_) { /* server not up yet */ }
7473
await new Promise(r => setTimeout(r, 1000));
7574
}
7675
if (!cancelled) setBackendError('Backend services failed to start. Please restart the application.');

client/src/Layout/index.test.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
22
import '@testing-library/jest-dom';
3-
import { render, screen, fireEvent, act } from '@testing-library/react';
3+
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
44

55
jest.mock('react-i18next', () => ({
66
useTranslation: () => ({
@@ -45,7 +45,8 @@ jest.mock('../utils/api', () => ({
4545
getInstance: () => ({
4646
getFingerPrintCount: jest.fn().mockResolvedValue({ success: true, message: 0 }),
4747
setStateLanguage: jest.fn().mockResolvedValue({ success: true }),
48-
checkWebSocket: jest.fn().mockResolvedValue({ success: true })
48+
checkWebSocket: jest.fn().mockResolvedValue({ success: true }),
49+
checkReadiness: jest.fn().mockResolvedValue({ success: true, checks: { server: true, webSocket: true, dbservice: true, toolService: true } })
4950
})
5051
}
5152
}));
@@ -64,12 +65,13 @@ describe('Layout', () => {
6465

6566
it('renders child component and sidebar', async () => {
6667
await act(async () => { render(<Layout Child={DummyChild} />); });
67-
expect(screen.getByTestId('child')).toBeInTheDocument();
68+
await waitFor(() => expect(screen.getByTestId('child')).toBeInTheDocument(), { timeout: 3000 });
6869
expect(screen.getByText('Web3ToolBox')).toBeInTheDocument();
6970
});
7071

7172
it('renders all menu items', async () => {
7273
await act(async () => { render(<Layout Child={DummyChild} />); });
74+
await waitFor(() => expect(screen.queryByText('layout.backendLoading')).not.toBeInTheDocument(), { timeout: 3000 });
7375
expect(screen.getByTitle('introduction')).toBeInTheDocument();
7476
expect(screen.getByTitle('chromeManage')).toBeInTheDocument();
7577
expect(screen.getByTitle('walletManage')).toBeInTheDocument();
@@ -79,13 +81,15 @@ describe('Layout', () => {
7981

8082
it('toggles sidebar collapse', async () => {
8183
await act(async () => { render(<Layout Child={DummyChild} />); });
84+
await waitFor(() => expect(screen.queryByText('layout.backendLoading')).not.toBeInTheDocument(), { timeout: 3000 });
8285
const toggleBtn = screen.getByLabelText('Collapse Navigation');
8386
fireEvent.click(toggleBtn);
8487
expect(window.localStorage.getItem('layout.sidebarCollapsed')).toBe('1');
8588
});
8689

8790
it('opens task offcanvas on taskExecuted event', async () => {
8891
await act(async () => { render(<Layout Child={DummyChild} />); });
92+
await waitFor(() => expect(screen.queryByText('layout.backendLoading')).not.toBeInTheDocument(), { timeout: 3000 });
8993
expect(screen.queryByTestId('task-offcanvas')).not.toBeInTheDocument();
9094
act(() => {
9195
eventEmitter.emit('taskExecuted');
@@ -95,12 +99,14 @@ describe('Layout', () => {
9599

96100
it('opens task offcanvas on button click', async () => {
97101
await act(async () => { render(<Layout Child={DummyChild} />); });
102+
await waitFor(() => expect(screen.queryByText('layout.backendLoading')).not.toBeInTheDocument(), { timeout: 3000 });
98103
fireEvent.click(screen.getByTestId('task-info-button'));
99104
expect(screen.getByTestId('task-offcanvas')).toBeInTheDocument();
100105
});
101106

102107
it('opens and uses language offcanvas', async () => {
103108
await act(async () => { render(<Layout Child={DummyChild} />); });
109+
await waitFor(() => expect(screen.queryByText('layout.backendLoading')).not.toBeInTheDocument(), { timeout: 3000 });
104110
fireEvent.click(screen.getByTitle('changeLang'));
105111
expect(screen.getByText('selectLang')).toBeInTheDocument();
106112
fireEvent.click(screen.getByText('English'));
@@ -110,11 +116,13 @@ describe('Layout', () => {
110116
it('starts collapsed when localStorage has collapsed=1', async () => {
111117
window.localStorage.setItem('layout.sidebarCollapsed', '1');
112118
await act(async () => { render(<Layout Child={DummyChild} />); });
119+
await waitFor(() => expect(screen.queryByText('layout.backendLoading')).not.toBeInTheDocument(), { timeout: 3000 });
113120
expect(screen.getByLabelText('Expand Navigation')).toBeInTheDocument();
114121
});
115122

116123
it('calls fetchFingerPrints on mount', async () => {
117124
await act(async () => { render(<Layout Child={DummyChild} />); });
125+
await waitFor(() => expect(screen.queryByText('layout.backendLoading')).not.toBeInTheDocument(), { timeout: 3000 });
118126
expect(mockFetchFingerPrints).toHaveBeenCalled();
119127
});
120128
});

client/src/utils/api.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,10 @@ class APIManager {
268268
console.log('[api] checkWebSocket response:', res?.data);
269269
return res.data;
270270
}
271+
async checkReadiness(){
272+
const res = await axios.get(`${this.baseUrl}/readiness`, { timeout: 3000 });
273+
return res.data;
274+
}
271275
async getTaskStatus(taskNames = []) {
272276
const res = await axios.post(`${this.baseUrl}/getTaskStatus`, { taskNames });
273277
return res.data;

server/router.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,49 @@ router.get('/checkWebSocket',async(req,res)=>{
238238
const message = await taskService.checkWebSocket();
239239
res.send(message);
240240
});
241+
242+
// Comprehensive readiness check — returns true only when ALL backend services are up
243+
router.get('/readiness', async (req, res) => {
244+
const checks = { server: true, webSocket: false, dbservice: false, toolService: false };
245+
try {
246+
// WebSocket
247+
const wsResult = await taskService.checkWebSocket();
248+
checks.webSocket = !!(wsResult && wsResult.success !== false);
249+
} catch (_) {}
250+
try {
251+
// dbservice (memory/knowledge)
252+
const dbRes = await memoryService.handleHealth(
253+
{ method: 'GET' },
254+
{ status: () => ({ json: (d) => d }), json: (d) => d }
255+
);
256+
// handleHealth is an Express handler, use proxyToDbService directly
257+
const http = require('http');
258+
await new Promise((resolve) => {
259+
const r = http.get('http://127.0.0.1:30002/health', { timeout: 2000 }, (resp) => {
260+
checks.dbservice = resp.statusCode === 200;
261+
resp.resume();
262+
resolve();
263+
});
264+
r.on('error', () => resolve());
265+
r.on('timeout', () => { r.destroy(); resolve(); });
266+
});
267+
} catch (_) {}
268+
try {
269+
// toolService
270+
const http = require('http');
271+
await new Promise((resolve) => {
272+
const r = http.get('http://127.0.0.1:30004/health', { timeout: 2000 }, (resp) => {
273+
checks.toolService = resp.statusCode === 200;
274+
resp.resume();
275+
resolve();
276+
});
277+
r.on('error', () => resolve());
278+
r.on('timeout', () => { r.destroy(); resolve(); });
279+
});
280+
} catch (_) {}
281+
const allReady = checks.server && checks.webSocket && checks.dbservice && checks.toolService;
282+
res.send({ success: allReady, checks });
283+
});
241284
router.post('/getTaskStatus', async (req, res) => {
242285
const { taskNames } = req.body || {};
243286
const message = taskService.getTaskRunningStatus(taskNames);

0 commit comments

Comments
 (0)