// @vitest-environment node import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; vi.mock('@electron/service/logger', () => { const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }; return { default: logger, logManager: logger, }; }); import { createErrorResponse, createRequest, createSuccessResponse, GatewayEventType, isNotification, isRequest, isResponse, } from '../electron/gateway/protocol'; import { dispatchJsonRpcNotification, dispatchProtocolEvent, } from '../electron/gateway/event-dispatch'; import { GatewayStateController } from '../electron/gateway/state'; import { createInitialGatewayDiagnostics } from '../electron/gateway/diagnostics'; type EmittedEvent = { event: string; payload: unknown; }; function createEmitterRecorder() { const events: EmittedEvent[] = []; return { events, emitter: { emit: vi.fn((event: string, payload: unknown) => { events.push({ event, payload }); return true; }), }, }; } describe('gateway protocol helpers', () => { it('creates JSON-RPC request and responses', () => { const request = createRequest('skills.status', { includeDisabled: true }, 'req-1'); expect(request).toEqual({ jsonrpc: '2.0', id: 'req-1', method: 'skills.status', params: { includeDisabled: true }, }); expect(createSuccessResponse('req-1', { ok: true })).toEqual({ jsonrpc: '2.0', id: 'req-1', result: { ok: true }, }); expect(createErrorResponse('req-1', -32001, 'not connected')).toEqual({ jsonrpc: '2.0', id: 'req-1', error: { code: -32001, message: 'not connected', data: undefined, }, }); }); it('generates a request id when one is not supplied', () => { const request = createRequest('gateway.ping'); expect(request.jsonrpc).toBe('2.0'); expect(typeof request.id).toBe('string'); expect(request.id).not.toHaveLength(0); }); it('detects requests, responses, and notifications', () => { const request = createRequest('chat.send', { text: 'hello' }, 'req-2'); const response = createSuccessResponse('req-2', { runId: 'run-1' }); const notification = { jsonrpc: '2.0' as const, method: GatewayEventType.MESSAGE_RECEIVED, params: { message: 'hi' }, }; expect(isRequest(request)).toBe(true); expect(isResponse(request)).toBe(false); expect(isNotification(request)).toBe(false); expect(isResponse(response)).toBe(true); expect(isRequest(response)).toBe(false); expect(isNotification(response)).toBe(false); expect(isNotification(notification)).toBe(true); expect(isRequest(notification)).toBe(false); expect(isResponse(notification)).toBe(false); }); }); describe('gateway event dispatch', () => { it('dispatches protocol chat and gateway.ready events', () => { const { emitter, events } = createEmitterRecorder(); dispatchProtocolEvent(emitter, 'chat', { message: 'delta' }); dispatchProtocolEvent(emitter, 'gateway.ready', { source: 'event' }); expect(events).toEqual([ { event: 'chat:message', payload: { message: { message: 'delta' } } }, { event: 'gateway:ready', payload: { source: 'event' } }, ]); }); it('dispatches protocol tool lifecycle events onto the normalized tool status channel', () => { const { emitter, events } = createEmitterRecorder(); dispatchProtocolEvent(emitter, GatewayEventType.TOOL_CALL_STARTED, { sessionKey: 'agent:test:main', runId: 'run-1', toolName: 'browser.open_url', }); dispatchProtocolEvent(emitter, GatewayEventType.TOOL_CALL_COMPLETED, { sessionKey: 'agent:test:main', runId: 'run-1', toolName: 'browser.open_url', durationMs: 42, }); expect(events).toEqual([ { event: 'tool:status', payload: { status: 'running', payload: { sessionKey: 'agent:test:main', runId: 'run-1', toolName: 'browser.open_url', }, }, }, { event: 'tool:status', payload: { status: 'completed', payload: { sessionKey: 'agent:test:main', runId: 'run-1', toolName: 'browser.open_url', durationMs: 42, }, }, }, ]); }); it('dispatches unknown protocol events to notification listeners', () => { const { emitter, events } = createEmitterRecorder(); dispatchProtocolEvent(emitter, 'skills.changed', { slug: 'minimax-xlsx' }); expect(events).toEqual([ { event: 'notification', payload: { method: 'skills.changed', params: { slug: 'minimax-xlsx' } }, }, ]); }); it('dispatches JSON-RPC notifications onto typed channels', () => { const { emitter, events } = createEmitterRecorder(); dispatchJsonRpcNotification(emitter, { jsonrpc: '2.0', method: GatewayEventType.CHANNEL_STATUS_CHANGED, params: { channelId: 'wx', status: 'connected' }, }); dispatchJsonRpcNotification(emitter, { jsonrpc: '2.0', method: GatewayEventType.MESSAGE_RECEIVED, params: { message: { text: 'hello' } }, }); dispatchJsonRpcNotification(emitter, { jsonrpc: '2.0', method: GatewayEventType.TOOL_CALL_STARTED, params: { sessionKey: 'agent:test:main', runId: 'run-2', toolName: 'browser.open_url' }, }); dispatchJsonRpcNotification(emitter, { jsonrpc: '2.0', method: GatewayEventType.ERROR, params: { message: 'gateway boom' }, }); expect(events[0]).toEqual({ event: 'notification', payload: { jsonrpc: '2.0', method: GatewayEventType.CHANNEL_STATUS_CHANGED, params: { channelId: 'wx', status: 'connected' }, }, }); expect(events[1]).toEqual({ event: 'channel:status', payload: { channelId: 'wx', status: 'connected' }, }); expect(events[2]).toEqual({ event: 'notification', payload: { jsonrpc: '2.0', method: GatewayEventType.MESSAGE_RECEIVED, params: { message: { text: 'hello' } }, }, }); expect(events[3]).toEqual({ event: 'chat:message', payload: { message: { text: 'hello' } }, }); expect(events[4]).toEqual({ event: 'notification', payload: { jsonrpc: '2.0', method: GatewayEventType.TOOL_CALL_STARTED, params: { sessionKey: 'agent:test:main', runId: 'run-2', toolName: 'browser.open_url' }, }, }); expect(events[5]).toEqual({ event: 'tool:status', payload: { status: 'running', payload: { sessionKey: 'agent:test:main', runId: 'run-2', toolName: 'browser.open_url' }, }, }); expect(events[6].event).toBe('notification'); expect(events[7].event).toBe('error'); expect((events[7].payload as Error).message).toBe('gateway boom'); }); }); describe('gateway state controller', () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-04-23T00:00:00.000Z')); }); afterEach(() => { vi.useRealTimers(); }); it('tracks transitions, connectivity, and uptime', () => { const emittedStatuses: Array> = []; const transitions: Array<[string, string]> = []; const controller = new GatewayStateController({ emitStatus: (status) => { emittedStatuses.push({ ...status }); }, onTransition: (previousState, nextState) => { transitions.push([previousState, nextState]); }, }); controller.setStatus({ state: 'starting', port: 18789 }); controller.setStatus({ state: 'running', port: 18789, connectedAt: Date.now() - 2_000 }); expect(controller.isConnected(false)).toBe(false); expect(controller.isConnected(true)).toBe(true); expect(emittedStatuses[0]).toMatchObject({ state: 'starting', port: 18789 }); expect(emittedStatuses[1]).toMatchObject({ state: 'running', port: 18789, connectedAt: Date.now() - 2_000, }); expect(emittedStatuses[1].uptime).toBe(2_000); expect(transitions).toEqual([ ['stopped', 'starting'], ['starting', 'running'], ]); vi.setSystemTime(new Date('2026-04-23T00:00:05.000Z')); expect(controller.getStatus().uptime).toBe(7_000); }); it('clears uptime when the gateway is no longer running', () => { const controller = new GatewayStateController({ emitStatus: () => {}, }); controller.setStatus({ state: 'running', port: 18789, connectedAt: Date.now() - 1_000 }); expect(controller.getStatus().uptime).toBe(1_000); controller.setStatus({ state: 'stopped' }); expect(controller.getStatus().uptime).toBeUndefined(); expect(controller.isConnected(true)).toBe(false); }); }); describe('gateway diagnostics helpers', () => { it('creates an empty diagnostics snapshot', () => { expect(createInitialGatewayDiagnostics()).toEqual({ consecutiveHeartbeatMisses: 0, consecutiveRpcFailures: 0, }); }); });