Skip to content

Commit d904abb

Browse files
authored
fix: http server not ready when app start (#1040)
* refactor: move generic error into separate file for reusing it * fix: handle http server not ready into a dedicate page * test: update * leftover * refactor: repalce class error with built-in es2023 cause * chore: align tsconfig libr and targeet to es2023
1 parent 5972e29 commit d904abb

File tree

8 files changed

+321
-55
lines changed

8 files changed

+321
-55
lines changed
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { render, screen, cleanup, act } from '@testing-library/react'
2+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
3+
import { StartingToolHive } from '../starting-toolhive'
4+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
5+
import { http, HttpResponse } from 'msw'
6+
import { server } from '../../mocks/node'
7+
8+
const mockNavigate = vi.fn()
9+
10+
vi.mock('@tanstack/react-router', () => ({
11+
useNavigate: () => mockNavigate,
12+
}))
13+
14+
vi.mock('../layout/top-nav/quit-confirmation-listener', () => ({
15+
QuitConfirmationListener: () => null,
16+
}))
17+
18+
vi.mock('../layout/top-nav/window-controls', () => ({
19+
WindowControls: () => null,
20+
}))
21+
22+
vi.mock('electron-log/renderer', () => ({
23+
default: {
24+
info: vi.fn(),
25+
error: vi.fn(),
26+
},
27+
}))
28+
29+
describe('StartingToolHive', () => {
30+
let queryClient: QueryClient
31+
32+
beforeEach(() => {
33+
queryClient = new QueryClient({
34+
defaultOptions: {
35+
queries: {
36+
retry: false,
37+
},
38+
},
39+
})
40+
vi.useFakeTimers()
41+
mockNavigate.mockClear()
42+
})
43+
44+
afterEach(() => {
45+
cleanup()
46+
vi.useRealTimers()
47+
})
48+
49+
it('should display loading state with loader', () => {
50+
// MSW handler is already configured in customHandlers to return 204
51+
render(
52+
<QueryClientProvider client={queryClient}>
53+
<StartingToolHive />
54+
</QueryClientProvider>
55+
)
56+
57+
expect(
58+
screen.getByText('Starting ToolHive configuration')
59+
).toBeInTheDocument()
60+
expect(
61+
screen.getByText(/checking your ToolHive configuration/i)
62+
).toBeInTheDocument()
63+
})
64+
65+
it('should throw error when health check fails', async () => {
66+
server.use(
67+
http.get('*/health', () => {
68+
return HttpResponse.error()
69+
})
70+
)
71+
72+
render(
73+
<QueryClientProvider client={queryClient}>
74+
<StartingToolHive />
75+
</QueryClientProvider>
76+
)
77+
78+
expect(
79+
screen.getByText('Starting ToolHive configuration')
80+
).toBeInTheDocument()
81+
82+
// Advance timers for all retries (10 retries × 300ms = 3000ms)
83+
await act(async () => {
84+
await vi.advanceTimersByTimeAsync(3100)
85+
})
86+
87+
// After all retries fail, error screen should be displayed
88+
expect(
89+
screen.queryByText('Starting ToolHive configuration')
90+
).not.toBeInTheDocument()
91+
expect(
92+
screen.getByText(/we're sorry, but something unexpected happened/i)
93+
).toBeInTheDocument()
94+
95+
expect(screen.getByText(/try reloading the app/i)).toBeInTheDocument()
96+
expect(screen.getByText('Try Again')).toBeInTheDocument()
97+
98+
expect(mockNavigate).not.toHaveBeenCalled()
99+
})
100+
101+
it('should navigate when health check succeeds', async () => {
102+
render(
103+
<QueryClientProvider client={queryClient}>
104+
<StartingToolHive />
105+
</QueryClientProvider>
106+
)
107+
108+
// Advance timers to complete the query
109+
await act(async () => {
110+
await vi.advanceTimersByTimeAsync(100)
111+
})
112+
113+
// After advancing timers, navigation should have been called
114+
expect(mockNavigate).toHaveBeenCalledWith({
115+
to: '/group/$groupName',
116+
params: { groupName: 'default' },
117+
})
118+
})
119+
})

renderer/src/common/components/error/__tests__/index.test.tsx

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { render, screen } from '@testing-library/react'
22
import { describe, expect, it, vi, beforeEach } from 'vitest'
33
import { Error as ErrorComponent } from '../index'
4+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
5+
import { http, HttpResponse } from 'msw'
6+
import { server } from '../../../mocks/node'
47

58
vi.mock('../../layout/top-nav/minimal', () => ({
69
TopNavMinimal: () => {
@@ -21,8 +24,24 @@ Object.defineProperty(window, 'electronAPI', {
2124
})
2225

2326
describe('Error', () => {
27+
let queryClient: QueryClient
28+
2429
beforeEach(() => {
30+
queryClient = new QueryClient({
31+
defaultOptions: {
32+
queries: {
33+
retry: false,
34+
},
35+
},
36+
})
2537
vi.clearAllMocks()
38+
39+
// Mock container-engines endpoint to avoid triggering ConnectionRefusedError
40+
server.use(
41+
http.get('*/container-engines', () => {
42+
return HttpResponse.json({ available: true })
43+
})
44+
)
2645
})
2746

2847
it('renders <KeyringError /> when error contains "OS keyring is not available" and platform is Linux', () => {
@@ -31,7 +50,11 @@ describe('Error', () => {
3150
mockElectronAPI.isLinux = true
3251
mockElectronAPI.platform = 'linux'
3352

34-
render(<ErrorComponent error={keyringError} />)
53+
render(
54+
<QueryClientProvider client={queryClient}>
55+
<ErrorComponent error={keyringError} />
56+
</QueryClientProvider>
57+
)
3558

3659
expect(screen.getByText('System Keyring Cannot be Reached')).toBeVisible()
3760
expect(
@@ -41,11 +64,17 @@ describe('Error', () => {
4164

4265
it('renders generic error when keyring error occurs on non-Linux platform', () => {
4366
const keyringError = new Error('OS keyring is not available')
67+
// Set containerEngineAvailable to avoid triggering ConnectionRefusedError
68+
Object.assign(keyringError, { containerEngineAvailable: true })
4469

4570
mockElectronAPI.isLinux = false
4671
mockElectronAPI.platform = 'win32'
4772

48-
render(<ErrorComponent error={keyringError} />)
73+
render(
74+
<QueryClientProvider client={queryClient}>
75+
<ErrorComponent error={keyringError} />
76+
</QueryClientProvider>
77+
)
4978

5079
expect(screen.getByText('Oops, something went wrong')).toBeVisible()
5180
expect(screen.getByText('OS keyring is not available')).toBeVisible()
@@ -60,11 +89,17 @@ describe('Error', () => {
6089
})
6190

6291
it('renders generic error properly', () => {
63-
const genericError = new Error('Network connection failed')
92+
const genericError = new Error('Something unexpected happened')
93+
// Set containerEngineAvailable to avoid triggering ConnectionRefusedError
94+
Object.assign(genericError, { containerEngineAvailable: true })
6495

65-
render(<ErrorComponent error={genericError} />)
96+
render(
97+
<QueryClientProvider client={queryClient}>
98+
<ErrorComponent error={genericError} />
99+
</QueryClientProvider>
100+
)
66101

67102
expect(screen.getByText('Oops, something went wrong')).toBeVisible()
68-
expect(screen.getByText('Network connection failed')).toBeVisible()
103+
expect(screen.getByText('Something unexpected happened')).toBeVisible()
69104
})
70105
})
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { AlertCircle } from 'lucide-react'
2+
import { LinkErrorDiscord } from '../workloads/link-error-discord'
3+
import { BaseErrorScreen } from './base-error-screen'
4+
5+
export function GenericError({ error }: { error?: Error }) {
6+
return (
7+
<BaseErrorScreen
8+
title="Oops, something went wrong"
9+
icon={<AlertCircle className="text-destructive size-12" />}
10+
>
11+
<p>
12+
We're sorry, but something unexpected happened. Please try reloading the
13+
app.
14+
</p>
15+
{error?.message && (
16+
<div className="bg-muted rounded-md p-3 text-sm">
17+
<code>{error.message}</code>
18+
</div>
19+
)}
20+
<p>
21+
If issues persist, contact the ToolHive team via <LinkErrorDiscord />
22+
</p>
23+
</BaseErrorScreen>
24+
)
25+
}
Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { AlertCircle } from 'lucide-react'
2-
import { BaseErrorScreen } from './base-error-screen'
31
import { KeyringError } from './keyring-error'
42
import { ConnectionRefusedError } from './connection-refused-error'
3+
import { GenericError } from './generic-error'
54

65
interface ErrorProps {
7-
error?: Error
6+
error?: Error & { containerEngineAvailable?: boolean }
87
}
98

109
export function Error({ error }: ErrorProps = {}) {
@@ -24,26 +23,11 @@ export function Error({ error }: ErrorProps = {}) {
2423
error?.toString().includes('connect ECONNREFUSED') ||
2524
error?.toString().includes('ENOTFOUND') ||
2625
error?.toString().includes('Network Error') ||
27-
error?.message?.includes('fetch') ||
28-
error?.message?.includes('failed to ping Docker server')
26+
error?.message?.includes('failed to ping Docker server') ||
27+
!error?.containerEngineAvailable
2928
) {
3029
return <ConnectionRefusedError />
3130
}
3231

33-
return (
34-
<BaseErrorScreen
35-
title="Oops, something went wrong"
36-
icon={<AlertCircle className="text-destructive size-12" />}
37-
>
38-
<p>
39-
We're sorry, but something unexpected happened. Please try reloading the
40-
app.
41-
</p>
42-
{error?.message && (
43-
<div className="bg-muted rounded-md p-3 text-sm">
44-
<code>{error.message}</code>
45-
</div>
46-
)}
47-
</BaseErrorScreen>
48-
)
32+
return <GenericError error={error} />
4933
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Loader } from 'lucide-react'
2+
import { IllustrationPackage } from './illustrations/illustration-package'
3+
import { Card, CardContent, CardHeader, CardTitle } from './ui/card'
4+
import { getHealth } from '@api/sdk.gen'
5+
import { useQuery } from '@tanstack/react-query'
6+
import { useEffect } from 'react'
7+
import log from 'electron-log/renderer'
8+
import { useNavigate } from '@tanstack/react-router'
9+
import { GenericError } from './error/generic-error'
10+
11+
export function StartingToolHive() {
12+
const navigate = useNavigate()
13+
14+
const { isLoading: isChecking, error } = useQuery({
15+
queryKey: ['health'],
16+
queryFn: () => getHealth(),
17+
staleTime: 0,
18+
gcTime: 0,
19+
retry: 10,
20+
retryDelay: 300,
21+
refetchOnWindowFocus: true,
22+
})
23+
const hasError = error && !isChecking
24+
25+
useEffect(() => {
26+
if (!error && !isChecking) {
27+
log.info(
28+
'[StartingToolHive] Health check successful, navigating to default group'
29+
)
30+
navigate({ to: '/group/$groupName', params: { groupName: 'default' } })
31+
}
32+
33+
if (hasError) {
34+
log.error(
35+
'[StartingToolHive] Error checking health:',
36+
JSON.stringify(error)
37+
)
38+
}
39+
}, [isChecking, error, navigate, hasError])
40+
41+
if (hasError) {
42+
return <GenericError error={error as Error} />
43+
}
44+
45+
return (
46+
<div
47+
className="mt-[64px] flex h-[calc(100vh-5rem-64px)] items-center
48+
justify-center px-8"
49+
>
50+
<Card
51+
className="mt-10 flex max-h-[min(600px,_100%)] w-full max-w-md flex-col"
52+
>
53+
<CardHeader className="text-center">
54+
<div className="mb-4 flex justify-center">
55+
<IllustrationPackage className="size-20" />
56+
</div>
57+
58+
<CardTitle className="text-xl font-semibold">
59+
Starting ToolHive configuration
60+
</CardTitle>
61+
</CardHeader>
62+
63+
<CardContent
64+
className="text-muted-foreground flex min-h-0 flex-1 flex-col
65+
items-center justify-center space-y-4 overflow-y-auto px-8"
66+
>
67+
<div className="text-muted-foreground text-center">
68+
We're checking your ToolHive configuration to ensure it's set up
69+
correctly.
70+
</div>
71+
<Loader className="size-10 animate-spin [animation-duration:1.5s]" />
72+
</CardContent>
73+
</Card>
74+
</div>
75+
)
76+
}

0 commit comments

Comments
 (0)