|
| 1 | +import * as os from 'os'; |
| 2 | +import * as path from 'path'; |
| 3 | +import * as fs from 'fs'; |
| 4 | + |
| 5 | +import { expect } from 'chai'; |
| 6 | +import WebSocket, { WebSocketServer } from 'ws'; |
| 7 | + |
| 8 | +import { |
| 9 | + UiOperationBridge, |
| 10 | + HtkOperation, |
| 11 | + REQUEST_TIMEOUT_MS |
| 12 | +} from '../../src/api/ui-operation-bridge'; |
| 13 | +import { socketRequest, requestTimeout } from '../../src/api/bridge-client'; |
| 14 | + |
| 15 | +const TEST_OPERATIONS: HtkOperation[] = [{ |
| 16 | + name: 'proxy.get-config', |
| 17 | + description: 'Get the proxy configuration', |
| 18 | + category: 'proxy', |
| 19 | + tiers: ['free'], |
| 20 | + inputSchema: { type: 'object', properties: {} } |
| 21 | +}]; |
| 22 | + |
| 23 | +// Connects a mock UI to the bridge, authenticates it and waits for readiness, |
| 24 | +// exactly as the real UI does on startup. |
| 25 | +async function connectMockUi(bridge: UiOperationBridge) { |
| 26 | + const wss = new WebSocketServer({ port: 0 }); |
| 27 | + |
| 28 | + const pair = await new Promise<{ clientWs: WebSocket, wss: WebSocketServer }>((resolve) => { |
| 29 | + wss.on('connection', (serverSideWs) => bridge.setWebSocket(serverSideWs as any)); |
| 30 | + wss.on('listening', () => { |
| 31 | + const { port } = wss.address() as { port: number }; |
| 32 | + const clientWs = new WebSocket(`ws://127.0.0.1:${port}`); |
| 33 | + clientWs.on('open', () => resolve({ clientWs, wss })); |
| 34 | + }); |
| 35 | + }); |
| 36 | + |
| 37 | + const authResult = new Promise<void>((resolve) => { |
| 38 | + pair.clientWs.on('message', function handler(data) { |
| 39 | + if (JSON.parse(data.toString()).type !== 'auth-result') return; |
| 40 | + pair.clientWs.removeListener('message', handler); |
| 41 | + resolve(); |
| 42 | + }); |
| 43 | + }); |
| 44 | + pair.clientWs.send(JSON.stringify({ type: 'auth', jwt: false })); |
| 45 | + await authResult; |
| 46 | + |
| 47 | + pair.clientWs.send(JSON.stringify({ type: 'operations', operations: TEST_OPERATIONS })); |
| 48 | + await new Promise<void>((resolve) => bridge.once('ready', resolve)); |
| 49 | + |
| 50 | + return pair; |
| 51 | +} |
| 52 | + |
| 53 | +describe("Bridge client", function () { |
| 54 | + |
| 55 | + // The slow test below deliberately waits out the previous 2 second client timeout: |
| 56 | + this.timeout(10_000); |
| 57 | + |
| 58 | + let bridge: UiOperationBridge; |
| 59 | + let pair: { clientWs: WebSocket, wss: WebSocketServer }; |
| 60 | + let socketDir: string; |
| 61 | + let socketPath: string; |
| 62 | + |
| 63 | + beforeEach(async () => { |
| 64 | + socketDir = fs.mkdtempSync(path.join(os.tmpdir(), 'htk-bridge-client-test-')); |
| 65 | + socketPath = path.join(socketDir, 'test.sock'); |
| 66 | + |
| 67 | + bridge = new UiOperationBridge(); |
| 68 | + await bridge.startApiServer(socketPath); |
| 69 | + pair = await connectMockUi(bridge); |
| 70 | + }); |
| 71 | + |
| 72 | + afterEach(() => { |
| 73 | + if (pair.clientWs.readyState === WebSocket.OPEN) pair.clientWs.close(); |
| 74 | + pair.wss.close(); |
| 75 | + bridge.destroy(); |
| 76 | + |
| 77 | + try { fs.rmSync(socketDir, { recursive: true, force: true }); } catch {} |
| 78 | + }); |
| 79 | + |
| 80 | + it("should wait for slow operations, up to the bridge's own request timeout", async () => { |
| 81 | + const slowResult = { port: 8000, ip: '127.0.0.1' }; |
| 82 | + |
| 83 | + // Serializing a large body in the UI can easily take longer than a moment: |
| 84 | + pair.clientWs.on('message', (data) => { |
| 85 | + const msg = JSON.parse(data.toString()); |
| 86 | + if (msg.type !== 'request') return; |
| 87 | + setTimeout(() => { |
| 88 | + pair.clientWs.send(JSON.stringify({ |
| 89 | + type: 'response', |
| 90 | + id: msg.id, |
| 91 | + result: slowResult |
| 92 | + })); |
| 93 | + }, 3000); |
| 94 | + }); |
| 95 | + |
| 96 | + const result = await socketRequest(socketPath, 'POST', '/api/execute', { |
| 97 | + name: 'proxy.get-config', |
| 98 | + args: {} |
| 99 | + }); |
| 100 | + |
| 101 | + expect(result).to.deep.equal(slowResult); |
| 102 | + }); |
| 103 | + |
| 104 | + it("should not give up before the bridge itself would", () => { |
| 105 | + // The bridge allows each operation REQUEST_TIMEOUT_MS to complete. If the |
| 106 | + // client gives up first, that budget is unreachable and the bridge's own |
| 107 | + // error message never gets to the user: |
| 108 | + expect(requestTimeout('/api/execute')).to.be.greaterThan(REQUEST_TIMEOUT_MS); |
| 109 | + }); |
| 110 | + |
| 111 | + it("should still give up quickly while looking for a server", () => { |
| 112 | + // Discovery tries each candidate socket path in turn, so a dead path has |
| 113 | + // to fail fast rather than hold up the ones behind it: |
| 114 | + expect(requestTimeout('/api/status')).to.be.lessThan(REQUEST_TIMEOUT_MS); |
| 115 | + expect(requestTimeout('/api/operations')).to.be.lessThan(REQUEST_TIMEOUT_MS); |
| 116 | + }); |
| 117 | +}); |
0 commit comments