cc-haha/desktop/src/api/websocket.test.ts
程序员阿江(Relakkes) 5c1583f852 Preserve recoverable browser H5 startup paths behind proxies
Remote browser bootstrap now keeps reverse-proxy path prefixes, recognizes IPv6 loopback as local, and converts remote health or verify failures into recoverable H5 connection prompts instead of falling back to the generic startup error surface. Token invalidation clears only the stored token so users can retry without re-entering the server address.

Constraint: Review fixes must stay inside the existing Task 4 browser-runtime scope without changing the Tauri localhost startup path
Rejected: Clear both stored server URL and token on remote bootstrap failures | it forces unnecessary re-entry after stale credentials
Directive: Any future browser-mode startup error for non-loopback backends should remain recoverable through H5ConnectionView unless the desktop/Tauri contract changes
Confidence: high
Scope-risk: narrow
Tested: cd desktop && bunx vitest run src/api/client.test.ts src/api/websocket.test.ts src/lib/desktopRuntime.test.ts src/components/layout/AppShell.test.tsx
Tested: cd desktop && bun run lint
Tested: git diff --check -- desktop/src/api/client.ts desktop/src/api/websocket.ts desktop/src/lib/desktopRuntime.ts desktop/src/components/layout/H5ConnectionView.tsx desktop/src/components/layout/AppShell.tsx desktop/src/api/client.test.ts desktop/src/api/websocket.test.ts desktop/src/lib/desktopRuntime.test.ts desktop/src/components/layout/AppShell.test.tsx
Not-tested: Live reverse-proxy browser session against a real remote H5 server
2026-05-10 00:35:42 +08:00

126 lines
3.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const clientMocks = vi.hoisted(() => ({
baseUrl: 'http://127.0.0.1:3456',
authToken: null as string | null,
}))
vi.mock('./client', () => ({
getBaseUrl: () => clientMocks.baseUrl,
getAuthToken: () => clientMocks.authToken,
}))
import { buildSessionWebSocketUrl, wsManager } from './websocket'
type SocketHandler = (() => void) | ((event: { data: string }) => void)
class FakeWebSocket {
static readonly CONNECTING = 0
static readonly OPEN = 1
static readonly CLOSING = 2
static readonly CLOSED = 3
static instances: FakeWebSocket[] = []
readonly url: string
readyState = FakeWebSocket.CONNECTING
onopen: SocketHandler | null = null
onmessage: SocketHandler | null = null
onclose: SocketHandler | null = null
onerror: SocketHandler | null = null
sent: string[] = []
constructor(url: string) {
this.url = url
FakeWebSocket.instances.push(this)
}
send(data: string) {
this.sent.push(data)
}
close() {
this.readyState = FakeWebSocket.CLOSED
;(this.onclose as (() => void) | null)?.()
}
open() {
this.readyState = FakeWebSocket.OPEN
;(this.onopen as (() => void) | null)?.()
}
fail() {
this.readyState = FakeWebSocket.CLOSED
;(this.onclose as (() => void) | null)?.()
}
}
describe('wsManager reconnect buffering', () => {
const originalWebSocket = globalThis.WebSocket
beforeEach(() => {
vi.useFakeTimers()
clientMocks.baseUrl = 'http://127.0.0.1:3456'
clientMocks.authToken = null
FakeWebSocket.instances = []
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
wsManager.disconnectAll()
})
afterEach(() => {
wsManager.disconnectAll()
globalThis.WebSocket = originalWebSocket
vi.useRealTimers()
})
it('replays queued messages after an unexpected reconnect', async () => {
wsManager.connect('session-reconnect')
const firstSocket = FakeWebSocket.instances[0]
expect(firstSocket?.url).toContain('/ws/session-reconnect')
firstSocket!.open()
wsManager.send('session-reconnect', { type: 'user_message', content: 'first' })
expect(firstSocket!.sent).toEqual([
JSON.stringify({ type: 'user_message', content: 'first' }),
])
firstSocket!.fail()
wsManager.send('session-reconnect', { type: 'user_message', content: 'queued while offline' })
await vi.advanceTimersByTimeAsync(1000)
const secondSocket = FakeWebSocket.instances[1]
expect(secondSocket).toBeDefined()
secondSocket!.open()
expect(secondSocket!.sent).toEqual([
JSON.stringify({ type: 'user_message', content: 'queued while offline' }),
])
})
it('builds websocket URLs from http and encodes token query params', () => {
clientMocks.baseUrl = 'http://10.0.0.2:3456'
clientMocks.authToken = 'h5 token/with?chars'
expect(buildSessionWebSocketUrl('session-reconnect')).toBe(
'ws://10.0.0.2:3456/ws/session-reconnect?token=h5+token%2Fwith%3Fchars',
)
})
it('upgrades https backends to wss', () => {
clientMocks.baseUrl = 'https://remote.example.com'
expect(buildSessionWebSocketUrl('secure-session')).toBe(
'wss://remote.example.com/ws/secure-session',
)
})
it('preserves reverse-proxy subpaths when building websocket URLs', () => {
clientMocks.baseUrl = 'https://public.example.com/app'
expect(buildSessionWebSocketUrl('s1')).toBe(
'wss://public.example.com/app/ws/s1',
)
})
})