From b39010d60245d0c15174c270c608199132d147e0 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Wed, 25 Feb 2026 11:55:14 -0800 Subject: [PATCH] fix(tests): resolve 4 post-phase test failures - platformClients.integration: iOS/Android push tests lacked setStatus() call before listNodes(), so platform filter excluded nodes. Added publishHeartbeat() to set platform on connection state. - server.test: agent.send now emits run_state events before done (Phase 1). Added sendAndWaitForDone() helper and updated test to find done event rather than assuming index 0. - handlers.test: updated agent.send/cancel assertions to use find() and pass send arg to agent.cancel, consistent with run_state events. - httpBody: req.destroy() closed socket before 413 response could be sent. Removed socket destruction from body reader; 413 responses now send Connection: close so Node closes the connection cleanly. Co-Authored-By: Claude Sonnet 4.6 --- src/automation/webhooks.ts | 2 +- .../platformClients.integration.test.ts | 2 ++ src/gateway/handlers/agent.ts | 8 +++-- src/gateway/handlers/handlers.test.ts | 36 ++++++++++++------- src/gateway/server.test.ts | 25 +++++++++++-- src/gateway/server.ts | 6 ++-- src/gateway/ui/pages/chat.test.ts | 5 ++- src/utils/httpBody.test.ts | 3 +- src/utils/httpBody.ts | 3 -- 9 files changed, 62 insertions(+), 28 deletions(-) diff --git a/src/automation/webhooks.ts b/src/automation/webhooks.ts index 51797f1..9f20199 100644 --- a/src/automation/webhooks.ts +++ b/src/automation/webhooks.ts @@ -136,7 +136,7 @@ export class WebhookHandler implements ChannelAdapter { } catch (err) { if (err instanceof RequestBodyTooLargeError) { auditLogger?.webhookDenied(webhookName, `Payload too large (>${this.maxRequestBodyBytes} bytes)`); - res.writeHead(413, { 'Content-Type': 'application/json' }); + res.writeHead(413, { 'Content-Type': 'application/json', 'Connection': 'close' }); res.end(JSON.stringify({ error: 'Payload too large' })); return false; } diff --git a/src/companion/platformClients.integration.test.ts b/src/companion/platformClients.integration.test.ts index cb393f0..1b50493 100644 --- a/src/companion/platformClients.integration.test.ts +++ b/src/companion/platformClients.integration.test.ts @@ -396,6 +396,7 @@ describe('platform clients integration', () => { try { await client.register(); + await client.publishHeartbeat(); const push = await client.registerPushToken({ token: 'd'.repeat(64), topic: 'dev.flynn.ios', @@ -424,6 +425,7 @@ describe('platform clients integration', () => { try { await client.register(); + await client.publishHeartbeat(); const push = await client.registerPushToken('e'.repeat(64)); expect(push.updated).toBe(true); diff --git a/src/gateway/handlers/agent.ts b/src/gateway/handlers/agent.ts index 7ab39dc..8f15222 100644 --- a/src/gateway/handlers/agent.ts +++ b/src/gateway/handlers/agent.ts @@ -212,7 +212,8 @@ export function createAgentHandlers(deps: AgentHandlerDeps) { command: parsedCommand, }); - const isCommand = Boolean(commandInput && deps.commandRegistry?.isCommand(commandInput)); + const commandRegistry = deps.commandRegistry; + const isCommand = Boolean(commandInput && commandRegistry?.isCommand(commandInput)); if (!isCommand) { activeRequestIds.set(connectionId, request.id); auditLogger?.runState?.({ @@ -231,8 +232,11 @@ export function createAgentHandlers(deps: AgentHandlerDeps) { } if (isCommand) { + if (!commandRegistry) { + throw new Error('Command registry is not available'); + } const sessionId = deps.sessionBridge.getSessionId(connectionId); - const commandResult = await deps.commandRegistry.execute(commandInput, { + const commandResult = await commandRegistry.execute(commandInput, { channel: 'ws', senderId: connectionId, sessionId: sessionId ?? `ws:${connectionId}`, diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index 550c3d3..cd81474 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -1146,8 +1146,11 @@ describe('agent handlers', () => { await handlers['agent.send'](req, send); expect(mockAgent.process).toHaveBeenCalledWith('hello', undefined); - expect(sent).toHaveLength(1); - const doneEvent = sent[0] as GatewayEvent; + const doneEvent = sent.find((msg) => (msg as GatewayEvent).event === 'done') as GatewayEvent | undefined; + expect(doneEvent).toBeTruthy(); + if (!doneEvent) { + throw new Error('done event not emitted'); + } expect(doneEvent.event).toBe('done'); expect(getPath(doneEvent.data, 'content')).toBe('response text'); }); @@ -1171,7 +1174,11 @@ describe('agent handlers', () => { { mimeType: 'image/png', data: 'iVBOR...', url: undefined, filename: 'screenshot.png' }, { mimeType: 'application/pdf', data: undefined, url: 'https://example.com/doc.pdf', filename: undefined }, ]); - const doneEvent = sent[0] as GatewayEvent; + const doneEvent = sent.find((msg) => (msg as GatewayEvent).event === 'done') as GatewayEvent | undefined; + expect(doneEvent).toBeTruthy(); + if (!doneEvent) { + throw new Error('done event not emitted'); + } expect(doneEvent.event).toBe('done'); }); @@ -1187,7 +1194,8 @@ describe('agent handlers', () => { await handlers['agent.send'](req, send); expect(mockAgent.process).toHaveBeenCalledWith('hi', []); - expect(sent).toHaveLength(1); + const doneEvent = sent.find((msg) => (msg as GatewayEvent).event === 'done'); + expect(doneEvent).toBeTruthy(); }); it('agent.send accepts attachment-only requests', async () => { @@ -1207,8 +1215,8 @@ describe('agent handlers', () => { expect(mockAgent.process).toHaveBeenCalledWith('', [ { mimeType: 'image/png', data: 'iVBOR...', url: undefined, filename: undefined }, ]); - expect(sent).toHaveLength(1); - expect((sent[0] as GatewayEvent).event).toBe('done'); + const doneEvent = sent.find((msg) => (msg as GatewayEvent).event === 'done'); + expect(doneEvent).toBeTruthy(); }); it('agent.send requires message or attachments', async () => { @@ -1247,10 +1255,8 @@ describe('agent handlers', () => { await Promise.all([p1, p2]); // Both should have completed — no AgentBusy error - expect(sent1).toHaveLength(1); - expect((sent1[0] as GatewayEvent).event).toBe('done'); - expect(sent2).toHaveLength(1); - expect((sent2[0] as GatewayEvent).event).toBe('done'); + expect(sent1.find((msg) => (msg as GatewayEvent).event === 'done')).toBeTruthy(); + expect(sent2.find((msg) => (msg as GatewayEvent).event === 'done')).toBeTruthy(); expect(mockAgent.process).toHaveBeenCalledTimes(2); }); @@ -1262,7 +1268,11 @@ describe('agent handlers', () => { await handlers['agent.send'](req, send); - const errorEvent = sent[0] as GatewayEvent; + const errorEvent = sent.find((msg) => (msg as GatewayEvent).event === 'error') as GatewayEvent | undefined; + expect(errorEvent).toBeTruthy(); + if (!errorEvent) { + throw new Error('error event not emitted'); + } expect(errorEvent.event).toBe('error'); expect(getPath(errorEvent.data, 'message')).toBe('model failed'); }); @@ -1291,7 +1301,7 @@ describe('agent handlers', () => { it('agent.cancel returns cancelled state', async () => { mockBridge.cancel.mockReturnValue(true); const req: GatewayRequest = { id: 7, method: 'agent.cancel', params: { connectionId: 'conn-1' } }; - const result = await handlers['agent.cancel'](req) as GatewayResponse; + const result = await handlers['agent.cancel'](req, vi.fn()) as GatewayResponse; expect(getPath(result.result, 'cancelled')).toBe(true); expect(getPath(result.result, 'message')).toContain('Cancellation requested'); @@ -1301,7 +1311,7 @@ describe('agent handlers', () => { it('agent.cancel returns not-cancelled when no active operation exists', async () => { mockBridge.cancel.mockReturnValue(false); const req: GatewayRequest = { id: 8, method: 'agent.cancel', params: { connectionId: 'conn-1' } }; - const result = await handlers['agent.cancel'](req) as GatewayResponse; + const result = await handlers['agent.cancel'](req, vi.fn()) as GatewayResponse; expect(getPath(result.result, 'cancelled')).toBe(false); expect(getPath(result.result, 'message')).toContain('No active operation'); diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index c34909c..439b3fb 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -102,6 +102,24 @@ function sendAndReceiveAll(ws: WebSocket, msg: object, count: number): Promise> { + return new Promise((resolve) => { + const messages: Array = []; + const handler = (data: Buffer) => { + const parsed = JSON.parse(data.toString()) as GatewayResponse | GatewayError | GatewayEvent; + messages.push(parsed); + const event = parsed as GatewayEvent; + if (event.event === 'done' || event.event === 'error') { + ws.off('message', handler); + resolve(messages); + } + }; + ws.on('message', handler); + ws.send(JSON.stringify(msg)); + }); +} + beforeAll(async () => { LISTEN_ALLOWED = await canListenOnLocalhost(); }); @@ -231,9 +249,10 @@ describe('GatewayServer integration', () => { } const ws = await createClient(); try { - // agent.send streams events — we expect a 'done' event - const messages = await sendAndReceiveAll(ws, { id: 4, method: 'agent.send', params: { message: 'hi' } }, 1); - const doneEvent = messages[0] as GatewayEvent; + // agent.send streams events (run_state + done) — wait for terminal done event + const messages = await sendAndWaitForDone(ws, { id: 4, method: 'agent.send', params: { message: 'hi' } }); + const doneEvent = messages.find((m) => (m as GatewayEvent).event === 'done') as GatewayEvent; + expect(doneEvent).toBeTruthy(); expect(doneEvent.id).toBe(4); expect(doneEvent.event).toBe('done'); expect((doneEvent.data as { content?: string }).content).toBe('Hello from Flynn!'); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 6f54286..2b2b4a5 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -823,7 +823,7 @@ export class GatewayServer { rawBody = await this.readRequestBody(req); } catch (err) { if (err instanceof RequestBodyTooLargeError) { - res.writeHead(413, { 'Content-Type': 'application/json' }); + res.writeHead(413, { 'Content-Type': 'application/json', 'Connection': 'close' }); res.end(JSON.stringify({ error: 'Payload too large' })); return true; } @@ -887,7 +887,7 @@ export class GatewayServer { rawBody = await this.readRequestBody(req); } catch (err) { if (err instanceof RequestBodyTooLargeError) { - res.writeHead(413, { 'Content-Type': 'application/json' }); + res.writeHead(413, { 'Content-Type': 'application/json', 'Connection': 'close' }); res.end(JSON.stringify({ error: 'Payload too large' })); return true; } @@ -974,7 +974,7 @@ export class GatewayServer { res.end(JSON.stringify({ ok: true })); } catch (err) { if (err instanceof RequestBodyTooLargeError) { - res.writeHead(413, { 'Content-Type': 'application/json' }); + res.writeHead(413, { 'Content-Type': 'application/json', 'Connection': 'close' }); res.end(JSON.stringify({ error: 'Payload too large' })); } else { console.error('Gmail push handler error:', err instanceof Error ? err.message : err); diff --git a/src/gateway/ui/pages/chat.test.ts b/src/gateway/ui/pages/chat.test.ts index 000a6bf..0c903de 100644 --- a/src/gateway/ui/pages/chat.test.ts +++ b/src/gateway/ui/pages/chat.test.ts @@ -248,8 +248,11 @@ describe('ChatPage wiring', () => { await Promise.resolve(); const statusLine = Array.from(root.querySelectorAll('div')) - .find((el: any) => String(el.textContent ?? '').startsWith('Run status:')); + .find((el: any) => String(el.textContent ?? '').startsWith('Run status:')) as any; expect(statusLine).toBeTruthy(); + if (!statusLine) { + throw new Error('Run status line not found'); + } expect(statusLine.classList.contains('hidden')).toBe(false); resolveResult?.({ content: 'ok' }); diff --git a/src/utils/httpBody.test.ts b/src/utils/httpBody.test.ts index 98d1dfc..bb405cf 100644 --- a/src/utils/httpBody.test.ts +++ b/src/utils/httpBody.test.ts @@ -29,7 +29,7 @@ describe('readRequestBody', () => { expect(req.destroyed).toBe(false); }); - it('rejects oversized body and destroys request', async () => { + it('rejects oversized body with RequestBodyTooLargeError', async () => { const req = new MockRequest(); const bodyPromise = readRequestBody(asIncoming(req), { maxBytes: 5 }); @@ -37,6 +37,5 @@ describe('readRequestBody', () => { req.emit('data', Buffer.from('6')); await expect(bodyPromise).rejects.toBeInstanceOf(RequestBodyTooLargeError); - expect(req.destroyed).toBe(true); }); }); diff --git a/src/utils/httpBody.ts b/src/utils/httpBody.ts index 36a1ce3..cff3a85 100644 --- a/src/utils/httpBody.ts +++ b/src/utils/httpBody.ts @@ -40,9 +40,6 @@ export function readRequestBody(req: IncomingMessage, opts: ReadRequestBodyOptio const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); totalBytes += buf.length; if (totalBytes > opts.maxBytes) { - if (typeof req.destroy === 'function') { - req.destroy(); - } fail(new RequestBodyTooLargeError(opts.maxBytes, totalBytes)); return; }