Reject SPA fallback responses during browser startup

Browser startup previously accepted any 200 response from /health. In Vite-only runs that can be the SPA fallback HTML, which lets startup continue until a later API call fails with an opaque JSON parse error.

Constraint: The H5 shell and Vite dev server can both return 200 for unknown routes.
Rejected: Only improve the visible StartupError copy | it would still misclassify the backend readiness state
Confidence: high
Scope-risk: narrow
Directive: Keep the healthcheck shape validation aligned with src/server/index.ts /health responses
Tested: cd desktop && bun run test src/lib/desktopRuntime.test.ts
Tested: cd desktop && bun run test src/lib/desktopRuntime.test.ts src/__tests__/generalSettings.test.tsx
Tested: cd desktop && bun run lint
Tested: bun run check:desktop
This commit is contained in:
程序员阿江(Relakkes) 2026-05-12 15:46:32 +08:00
parent 417e8eaa4f
commit 20c32ff1cf
2 changed files with 43 additions and 10 deletions

View File

@ -28,6 +28,10 @@ import {
saveAndVerifyH5Connection,
} from './desktopRuntime'
function healthOkResponse() {
return Response.json({ status: 'ok' })
}
describe('desktopRuntime browser H5 bootstrap', () => {
const originalFetch = globalThis.fetch
@ -66,7 +70,7 @@ describe('desktopRuntime browser H5 bootstrap', () => {
it('clears an invalid token but preserves the remembered remote server URL', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 200 }),
healthOkResponse(),
) as typeof fetch
clientMocks.postVerify.mockRejectedValueOnce(
Object.assign(new Error('Invalid or missing H5 access token'), { status: 401 }),
@ -88,7 +92,7 @@ describe('desktopRuntime browser H5 bootstrap', () => {
window.history.pushState({}, '', '/?serverUrl=http%3A%2F%2F%5B%3A%3A1%5D%3A3456')
window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'remote-token')
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 200 }),
healthOkResponse(),
) as typeof fetch
await expect(initializeDesktopServerUrl()).resolves.toBe('http://[::1]:3456')
@ -100,7 +104,7 @@ describe('desktopRuntime browser H5 bootstrap', () => {
it('uses the current browser origin when the H5 shell is served by the desktop server', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 200 }),
healthOkResponse(),
) as typeof fetch
await expect(initializeDesktopServerUrl()).resolves.toBe(window.location.origin)
@ -115,12 +119,31 @@ describe('desktopRuntime browser H5 bootstrap', () => {
})
})
it('does not treat a Vite SPA fallback response as a desktop server healthcheck', async () => {
vi.useFakeTimers()
globalThis.fetch = vi.fn().mockResolvedValue(
new Response('<!doctype html>', {
status: 200,
headers: { 'content-type': 'text/html' },
}),
) as typeof fetch
const startup = expect(initializeDesktopServerUrl()).rejects.toThrow(
`Server healthcheck failed: healthcheck returned non-JSON response from ${window.location.origin}/health`,
)
await vi.runAllTimersAsync()
await startup
expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith(window.location.origin)
expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null)
})
it('prefers an explicit Vite desktop server URL over the dev server origin', async () => {
clientMocks.defaultBaseUrl = 'http://127.0.0.1:55189'
clientMocks.explicitDefaultBaseUrl = true
window.history.pushState({}, '', '/')
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 200 }),
healthOkResponse(),
) as typeof fetch
await expect(initializeDesktopServerUrl()).resolves.toBe('http://127.0.0.1:55189')
@ -155,7 +178,7 @@ describe('desktopRuntime browser H5 bootstrap', () => {
it('normalizes remote verify failures like disabled H5 or CORS into recoverable H5 errors', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 200 }),
healthOkResponse(),
) as typeof fetch
clientMocks.postVerify.mockRejectedValueOnce(new TypeError('Failed to fetch'))
@ -174,7 +197,7 @@ describe('desktopRuntime browser H5 bootstrap', () => {
it('requires a token when browser WebUI connects to a LAN-bound server', async () => {
window.history.pushState({}, '', '/?serverUrl=http%3A%2F%2F192.168.0.102%3A28670')
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 200 }),
healthOkResponse(),
) as typeof fetch
await expect(initializeDesktopServerUrl()).rejects.toMatchObject({
@ -192,7 +215,7 @@ describe('desktopRuntime browser H5 bootstrap', () => {
it('uses and persists an H5 token from the QR launch URL', async () => {
window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Fpublic.example.com%2Fapp&h5Token=qr-token')
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 200 }),
healthOkResponse(),
) as typeof fetch
clientMocks.postVerify.mockResolvedValueOnce({ ok: true })
@ -207,7 +230,7 @@ describe('desktopRuntime browser H5 bootstrap', () => {
it('shows the H5 token recovery view when a local browser connects to an auth-required LAN server', async () => {
window.history.pushState({}, '', '/?serverUrl=http%3A%2F%2F192.168.0.102%3A28670')
globalThis.fetch = vi.fn()
.mockResolvedValueOnce(new Response(null, { status: 200 }))
.mockResolvedValueOnce(healthOkResponse())
.mockResolvedValueOnce(new Response(null, { status: 401 })) as typeof fetch
await expect(initializeDesktopServerUrl()).rejects.toMatchObject({

View File

@ -202,9 +202,19 @@ async function waitForHealth(serverUrl: string) {
cache: 'no-store',
})
if (response.ok) {
return
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.toLowerCase().includes('application/json')) {
lastError = new Error(`healthcheck returned non-JSON response from ${serverUrl}/health`)
} else {
const body = await response.json().catch(() => null)
if (body && typeof body === 'object' && 'status' in body && body.status === 'ok') {
return
}
lastError = new Error(`healthcheck returned invalid response from ${serverUrl}/health`)
}
} else {
lastError = new Error(`healthcheck returned ${response.status}`)
}
lastError = new Error(`healthcheck returned ${response.status}`)
} catch (error) {
lastError = error
}