Files
aryx/tests/main/sidecarProcess.test.ts
T

339 lines
8.9 KiB
TypeScript

import { EventEmitter } from 'node:events';
import { describe, expect, mock, test } from 'bun:test';
import type {
RunTurnCommand,
SidecarCapabilities,
WorkflowCheckpointSavedEvent,
WorkflowDiagnosticEvent,
} 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', () => {
const electronMock = {
app: {
isPackaged: false,
getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx',
},
};
return {
...electronMock,
default: electronMock,
};
});
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;
});
test('routes workflow diagnostic events through the turn-scoped callback', async () => {
spawnedProcesses.length = 0;
const client = new SidecarClient();
const diagnostics: WorkflowDiagnosticEvent[] = [];
const command: RunTurnCommand = {
type: 'run-turn',
requestId: 'turn-1',
sessionId: 'session-1',
projectPath: 'C:\\workspace\\project',
pattern: {
id: 'pattern-1',
name: 'Single Agent',
description: '',
mode: 'single',
availability: 'available',
maxIterations: 1,
agents: [
{
id: 'agent-1',
name: 'Primary',
description: '',
instructions: 'Help with the request.',
model: 'gpt-5.4',
},
],
createdAt: '2026-04-01T00:00:00.000Z',
updatedAt: '2026-04-01T00:00:00.000Z',
},
messages: [],
};
const turn = client.runTurn(
command,
async () => undefined,
async () => undefined,
async () => undefined,
async () => undefined,
async () => undefined,
async () => undefined,
async () => undefined,
async (event) => {
if (event.type === 'workflow-diagnostic') {
diagnostics.push(event);
}
},
);
await Promise.resolve();
expect(spawnedProcesses).toHaveLength(1);
spawnedProcesses[0]!.emitStdout(
`${JSON.stringify({
type: 'workflow-diagnostic',
requestId: command.requestId,
sessionId: command.sessionId,
severity: 'error',
diagnosticKind: 'executor-failed',
message: 'Tool crashed.',
agentId: 'agent-1',
agentName: 'Primary',
executorId: 'agent-1',
exceptionType: 'InvalidOperationException',
} satisfies WorkflowDiagnosticEvent)}\n`,
);
spawnedProcesses[0]!.emitStdout(
`${JSON.stringify({
type: 'turn-complete',
requestId: command.requestId,
sessionId: command.sessionId,
messages: [],
cancelled: false,
})}\n`,
);
spawnedProcesses[0]!.emitStdout(
`${JSON.stringify({
type: 'command-complete',
requestId: command.requestId,
})}\n`,
);
await expect(turn).resolves.toEqual([]);
expect(diagnostics).toEqual([
expect.objectContaining({
type: 'workflow-diagnostic',
severity: 'error',
diagnosticKind: 'executor-failed',
message: 'Tool crashed.',
executorId: 'agent-1',
}),
]);
const dispose = client.dispose();
spawnedProcesses[0]!.completeExit();
await dispose;
});
test('routes workflow checkpoint events through the turn-scoped callback', async () => {
spawnedProcesses.length = 0;
const client = new SidecarClient();
const checkpoints: WorkflowCheckpointSavedEvent[] = [];
const command: RunTurnCommand = {
type: 'run-turn',
requestId: 'turn-1',
sessionId: 'session-1',
projectPath: 'C:\\workspace\\project',
pattern: {
id: 'pattern-1',
name: 'Handoff',
description: '',
mode: 'handoff',
availability: 'available',
maxIterations: 1,
agents: [
{
id: 'agent-1',
name: 'Primary',
description: '',
instructions: 'Help with the request.',
model: 'gpt-5.4',
},
],
createdAt: '2026-04-01T00:00:00.000Z',
updatedAt: '2026-04-01T00:00:00.000Z',
},
messages: [],
};
const turn = client.runTurn(
command,
async () => undefined,
async () => undefined,
async () => undefined,
async () => undefined,
async () => undefined,
async () => undefined,
async () => undefined,
async (event) => {
if (event.type === 'workflow-checkpoint-saved') {
checkpoints.push(event);
}
},
);
await Promise.resolve();
expect(spawnedProcesses).toHaveLength(1);
spawnedProcesses[0]!.emitStdout(
`${JSON.stringify({
type: 'workflow-checkpoint-saved',
requestId: command.requestId,
sessionId: command.sessionId,
workflowSessionId: 'turn-1',
checkpointId: 'checkpoint-1',
storePath: 'C:\\Users\\tester\\AppData\\Local\\Aryx\\workflow-checkpoints\\session-1\\turn-1',
stepNumber: 2,
} satisfies WorkflowCheckpointSavedEvent)}\n`,
);
spawnedProcesses[0]!.emitStdout(
`${JSON.stringify({
type: 'turn-complete',
requestId: command.requestId,
sessionId: command.sessionId,
messages: [],
cancelled: false,
})}\n`,
);
spawnedProcesses[0]!.emitStdout(
`${JSON.stringify({
type: 'command-complete',
requestId: command.requestId,
})}\n`,
);
await expect(turn).resolves.toEqual([]);
expect(checkpoints).toEqual([
expect.objectContaining({
type: 'workflow-checkpoint-saved',
workflowSessionId: 'turn-1',
checkpointId: 'checkpoint-1',
stepNumber: 2,
}),
]);
const dispose = client.dispose();
spawnedProcesses[0]!.completeExit();
await dispose;
});
});