From 46b5de472e9174cb1e30bb44ad7166672a9e9530 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Wed, 25 Mar 2026 22:28:36 +0100 Subject: [PATCH] tests: some additional tests --- .../appServiceSidecarCapabilities.test.ts | 118 +++++++++++++++ tests/main/sidecarProcess.test.ts | 138 ++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 tests/main/appServiceSidecarCapabilities.test.ts create mode 100644 tests/main/sidecarProcess.test.ts diff --git a/tests/main/appServiceSidecarCapabilities.test.ts b/tests/main/appServiceSidecarCapabilities.test.ts new file mode 100644 index 0000000..c50014e --- /dev/null +++ b/tests/main/appServiceSidecarCapabilities.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, mock, test } from 'bun:test'; + +import type { SidecarCapabilities } from '@shared/contracts/sidecar'; + +mock.module('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => 'C:\\workspace\\personal\\repositories\\eryx', + getPath: () => 'C:\\workspace\\personal\\repositories\\eryx\\tests\\fixtures', + }, + dialog: { + showOpenDialog: async () => ({ canceled: true, filePaths: [] }), + }, + shell: { + openPath: async () => '', + }, +})); + +mock.module('keytar', () => ({ + default: { + getPassword: async () => null, + setPassword: async () => undefined, + deletePassword: async () => false, + }, +})); + +const { EryxAppService } = await import('@main/EryxAppService'); + +const SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE = + 'The .NET sidecar was stopped before the command completed.'; + +const CAPABILITIES_FIXTURE: SidecarCapabilities = { + runtime: 'dotnet-maf', + modes: { + single: { available: true }, + sequential: { available: true }, + concurrent: { available: true }, + handoff: { available: true }, + magentic: { available: false, reason: 'Not yet supported.' }, + 'group-chat': { available: true }, + }, + models: [], + runtimeTools: [], + connection: { + status: 'ready', + summary: 'Connected', + checkedAt: '2026-03-25T00:00:00.000Z', + }, +}; + +function setSidecar( + service: InstanceType, + describeCapabilities: () => Promise, +): void { + ( + service as unknown as { + sidecar: { + describeCapabilities: () => Promise; + dispose: () => Promise; + }; + } + ).sidecar = { + describeCapabilities, + dispose: async () => undefined, + }; +} + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + + return { promise, resolve }; +} + +describe('EryxAppService sidecar capabilities', () => { + test('retries describe-capabilities after an intentional sidecar stop', async () => { + const service = new EryxAppService(); + let attempts = 0; + + setSidecar(service, async () => { + attempts += 1; + if (attempts === 1) { + throw new Error(SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE); + } + + return CAPABILITIES_FIXTURE; + }); + + await expect(service.describeSidecarCapabilities()).resolves.toEqual(CAPABILITIES_FIXTURE); + expect(attempts).toBe(2); + }); + + test('coalesces concurrent capability requests while the cache is empty', async () => { + const service = new EryxAppService(); + const deferred = createDeferred(); + let calls = 0; + + setSidecar(service, async () => { + calls += 1; + return deferred.promise; + }); + + const first = service.describeSidecarCapabilities(); + const second = service.describeSidecarCapabilities(); + + expect(calls).toBe(1); + + deferred.resolve(CAPABILITIES_FIXTURE); + + await expect(first).resolves.toEqual(CAPABILITIES_FIXTURE); + await expect(second).resolves.toEqual(CAPABILITIES_FIXTURE); + await expect(service.describeSidecarCapabilities()).resolves.toEqual(CAPABILITIES_FIXTURE); + + expect(calls).toBe(1); + }); +}); diff --git a/tests/main/sidecarProcess.test.ts b/tests/main/sidecarProcess.test.ts new file mode 100644 index 0000000..01a8c8c --- /dev/null +++ b/tests/main/sidecarProcess.test.ts @@ -0,0 +1,138 @@ +import { EventEmitter } from 'node:events'; + +import { describe, expect, mock, test } from 'bun:test'; + +import type { SidecarCapabilities } from '@shared/contracts/sidecar'; + +class FakeReadableStream extends EventEmitter { + setEncoding(_encoding: BufferEncoding): void {} +} + +class FakeWritableStream { + readonly writes: string[] = []; + + write(chunk: string): boolean { + this.writes.push(chunk); + return true; + } +} + +class FakeChildProcess extends EventEmitter { + readonly stdout = new FakeReadableStream(); + readonly stderr = new FakeReadableStream(); + readonly stdin = new FakeWritableStream(); + exitCode: number | null = null; + killed = false; + + kill(): boolean { + this.killed = true; + return true; + } + + emitStdout(line: string): void { + this.stdout.emit('data', line); + } + + completeExit(code = 0): void { + this.exitCode = code; + this.emit('exit', code, null); + this.emit('close', code, null); + } +} + +const spawnedProcesses: FakeChildProcess[] = []; + +mock.module('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => 'C:\\workspace\\personal\\repositories\\eryx', + }, +})); + +mock.module('node:child_process', () => ({ + spawn: () => { + const child = new FakeChildProcess(); + spawnedProcesses.push(child); + return child; + }, +})); + +const { SidecarClient } = await import('@main/sidecar/sidecarProcess'); + +const CAPABILITIES_FIXTURE: SidecarCapabilities = { + runtime: 'dotnet-maf', + modes: { + single: { available: true }, + sequential: { available: true }, + concurrent: { available: true }, + handoff: { available: true }, + magentic: { available: true }, + 'group-chat': { available: true }, + }, + models: [], + runtimeTools: [], + connection: { + status: 'ready', + summary: 'Connected', + checkedAt: '2026-03-25T00:00:00.000Z', + }, +}; + +function getRequestId(process: FakeChildProcess): string { + const rawRequest = process.stdin.writes.at(-1); + if (!rawRequest) { + throw new Error('Expected the fake sidecar to receive a request.'); + } + + return (JSON.parse(rawRequest.trim()) as { requestId: string }).requestId; +} + +describe('SidecarClient', () => { + test('waits for the disposed sidecar to fully close before spawning a replacement process', async () => { + spawnedProcesses.length = 0; + const client = new SidecarClient(); + + const firstCapabilities = client.describeCapabilities(); + await Promise.resolve(); + expect(spawnedProcesses).toHaveLength(1); + spawnedProcesses[0]!.emitStdout( + `${JSON.stringify({ + type: 'capabilities', + requestId: getRequestId(spawnedProcesses[0]!), + capabilities: CAPABILITIES_FIXTURE, + })}\n`, + ); + await expect(firstCapabilities).resolves.toEqual(CAPABILITIES_FIXTURE); + + let disposeCompleted = false; + const disposePromise = client.dispose().then(() => { + disposeCompleted = true; + }); + + const replacementCapabilities = client.describeCapabilities(); + + await Promise.resolve(); + + expect(disposeCompleted).toBe(false); + expect(spawnedProcesses).toHaveLength(1); + + spawnedProcesses[0]!.completeExit(); + await disposePromise; + + expect(disposeCompleted).toBe(true); + expect(spawnedProcesses).toHaveLength(2); + + spawnedProcesses[1]!.emitStdout( + `${JSON.stringify({ + type: 'capabilities', + requestId: getRequestId(spawnedProcesses[1]!), + capabilities: CAPABILITIES_FIXTURE, + })}\n`, + ); + await expect(replacementCapabilities).resolves.toEqual(CAPABILITIES_FIXTURE); + + const finalDispose = client.dispose(); + spawnedProcesses[1]!.completeExit(); + await finalDispose; + }); +});