tests: some additional tests

This commit is contained in:
David Kaya
2026-03-25 22:28:36 +01:00
parent 628fdff29e
commit 46b5de472e
2 changed files with 256 additions and 0 deletions
@@ -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<typeof EryxAppService>,
describeCapabilities: () => Promise<SidecarCapabilities>,
): void {
(
service as unknown as {
sidecar: {
describeCapabilities: () => Promise<SidecarCapabilities>;
dispose: () => Promise<void>;
};
}
).sidecar = {
describeCapabilities,
dispose: async () => undefined,
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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<SidecarCapabilities>();
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);
});
});
+138
View File
@@ -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;
});
});