mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-26 21:03:58 +02:00
tests: some additional tests
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user