mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-10 05:38:45 +02:00
feat: add integrated terminal backend
- add a PTY manager with platform shell resolution and streamed data/exit events - expose terminal lifecycle and terminal height IPC through preload and app service - persist terminal panel height in workspace settings and document the backend contract - add backend tests and validate native packaging with bun run package Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import type { ProjectRecord } from '@shared/domain/project';
|
||||
import type { TerminalSnapshot } from '@shared/domain/terminal';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||
|
||||
const TIMESTAMP = '2026-03-28T00:00:00.000Z';
|
||||
|
||||
mock.module('electron', () => {
|
||||
const electronMock = {
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx',
|
||||
getPath: () => 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures',
|
||||
},
|
||||
dialog: {
|
||||
showOpenDialog: async () => ({ canceled: true, filePaths: [] }),
|
||||
},
|
||||
shell: {
|
||||
openPath: async () => '',
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...electronMock,
|
||||
default: electronMock,
|
||||
};
|
||||
});
|
||||
|
||||
mock.module('keytar', () => ({
|
||||
default: {
|
||||
getPassword: async () => null,
|
||||
setPassword: async () => undefined,
|
||||
deletePassword: async () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
const { AryxAppService } = await import('@main/AryxAppService');
|
||||
|
||||
function createProject(overrides?: Partial<ProjectRecord>): ProjectRecord {
|
||||
return {
|
||||
id: 'project-1',
|
||||
name: 'Project One',
|
||||
path: 'C:\\workspace\\project-one',
|
||||
addedAt: TIMESTAMP,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createSession(patternId: string, overrides?: Partial<SessionRecord>): SessionRecord {
|
||||
return {
|
||||
id: 'session-1',
|
||||
projectId: 'project-1',
|
||||
patternId,
|
||||
title: 'Terminal Session',
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
status: 'idle',
|
||||
messages: [],
|
||||
runs: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createTerminalSnapshot(overrides?: Partial<TerminalSnapshot>): TerminalSnapshot {
|
||||
return {
|
||||
cwd: 'C:\\workspace\\project-one',
|
||||
shell: 'PowerShell',
|
||||
pid: 100,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createService(workspace: WorkspaceState, terminalSnapshot = createTerminalSnapshot()) {
|
||||
let saveCalls = 0;
|
||||
const terminalCreateCwds: string[] = [];
|
||||
const terminalRestartCwds: string[] = [];
|
||||
const writes: string[] = [];
|
||||
const resizes: Array<{ cols: number; rows: number }> = [];
|
||||
let killCalls = 0;
|
||||
const service = new AryxAppService();
|
||||
const internals = service as unknown as Record<string, unknown>;
|
||||
|
||||
internals.workspaceRepository = {
|
||||
load: async () => workspace,
|
||||
save: async () => {
|
||||
saveCalls += 1;
|
||||
},
|
||||
};
|
||||
internals.syncUserDiscoveredTooling = async () => false;
|
||||
internals.syncProjectDiscoveredTooling = async () => false;
|
||||
internals.syncProjectCustomization = async () => false;
|
||||
internals.pruneUnavailableSessionToolingSelections = () => false;
|
||||
internals.pruneUnavailableApprovalTools = async () => false;
|
||||
internals.didScheduleInitialProjectGitRefresh = true;
|
||||
internals.ptyManager = {
|
||||
create: async (cwd: string) => {
|
||||
terminalCreateCwds.push(cwd);
|
||||
return { ...terminalSnapshot, cwd };
|
||||
},
|
||||
restart: async (cwd: string) => {
|
||||
terminalRestartCwds.push(cwd);
|
||||
return { ...terminalSnapshot, cwd };
|
||||
},
|
||||
kill: () => {
|
||||
killCalls += 1;
|
||||
},
|
||||
write: (data: string) => {
|
||||
writes.push(data);
|
||||
},
|
||||
resize: (cols: number, rows: number) => {
|
||||
resizes.push({ cols, rows });
|
||||
},
|
||||
getSnapshot: () => terminalSnapshot,
|
||||
dispose: () => undefined,
|
||||
on: () => internals.ptyManager,
|
||||
};
|
||||
|
||||
return {
|
||||
service,
|
||||
getSaveCalls: () => saveCalls,
|
||||
terminalCreateCwds,
|
||||
terminalRestartCwds,
|
||||
writes,
|
||||
resizes,
|
||||
getKillCalls: () => killCalls,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AryxAppService terminal integration', () => {
|
||||
test('uses the selected session cwd when creating and restarting the terminal', async () => {
|
||||
const workspace = createWorkspaceSeed();
|
||||
const pattern = workspace.patterns[0];
|
||||
if (!pattern) {
|
||||
throw new Error('Expected a seeded pattern.');
|
||||
}
|
||||
|
||||
workspace.projects = [createProject()];
|
||||
workspace.sessions = [createSession(pattern.id, { cwd: 'C:\\workspace\\scratchpad\\session-1' })];
|
||||
workspace.selectedProjectId = 'project-1';
|
||||
workspace.selectedSessionId = 'session-1';
|
||||
|
||||
const { service, terminalCreateCwds, terminalRestartCwds } = createService(workspace);
|
||||
|
||||
await service.createTerminal();
|
||||
await service.restartTerminal();
|
||||
|
||||
expect(terminalCreateCwds).toEqual(['C:\\workspace\\scratchpad\\session-1']);
|
||||
expect(terminalRestartCwds).toEqual(['C:\\workspace\\scratchpad\\session-1']);
|
||||
});
|
||||
|
||||
test('falls back to the selected project path and delegates terminal controls', async () => {
|
||||
const workspace = createWorkspaceSeed();
|
||||
workspace.projects = [createProject()];
|
||||
workspace.selectedProjectId = 'project-1';
|
||||
|
||||
const { service, terminalCreateCwds, writes, resizes, getKillCalls } = createService(workspace);
|
||||
|
||||
await expect(service.describeTerminal()).resolves.toEqual(createTerminalSnapshot());
|
||||
await service.createTerminal();
|
||||
service.writeTerminal('dir\r');
|
||||
service.resizeTerminal(120, 40);
|
||||
await service.killTerminal();
|
||||
|
||||
expect(terminalCreateCwds).toEqual(['C:\\workspace\\project-one']);
|
||||
expect(writes).toEqual(['dir\r']);
|
||||
expect(resizes).toEqual([{ cols: 120, rows: 40 }]);
|
||||
expect(getKillCalls()).toBe(1);
|
||||
});
|
||||
|
||||
test('normalizes and persists terminal height settings', async () => {
|
||||
const workspace = createWorkspaceSeed();
|
||||
const { service, getSaveCalls } = createService(workspace);
|
||||
|
||||
await service.setTerminalHeight(240.4);
|
||||
expect(workspace.settings.terminalHeight).toBe(240);
|
||||
expect(getSaveCalls()).toBe(1);
|
||||
|
||||
await service.setTerminalHeight(240);
|
||||
expect(getSaveCalls()).toBe(1);
|
||||
|
||||
await service.setTerminalHeight(0);
|
||||
expect(workspace.settings.terminalHeight).toBeUndefined();
|
||||
expect(getSaveCalls()).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { TerminalExitInfo, TerminalSnapshot } from '@shared/domain/terminal';
|
||||
import { PtyManager } from '@main/services/ptyManager';
|
||||
|
||||
class FakePty {
|
||||
readonly pid: number;
|
||||
readonly writes: string[] = [];
|
||||
readonly resizeCalls: Array<{ cols: number; rows: number }> = [];
|
||||
killCalls = 0;
|
||||
private readonly dataListeners = new Set<(data: string) => void>();
|
||||
private readonly exitListeners = new Set<(event: TerminalExitInfo) => void>();
|
||||
|
||||
constructor(pid: number) {
|
||||
this.pid = pid;
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
this.writes.push(data);
|
||||
}
|
||||
|
||||
resize(cols: number, rows: number): void {
|
||||
this.resizeCalls.push({ cols, rows });
|
||||
}
|
||||
|
||||
kill(): void {
|
||||
this.killCalls += 1;
|
||||
this.emitExit({ exitCode: 0 });
|
||||
}
|
||||
|
||||
onData(listener: (data: string) => void): { dispose(): void } {
|
||||
this.dataListeners.add(listener);
|
||||
return {
|
||||
dispose: () => {
|
||||
this.dataListeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onExit(listener: (event: TerminalExitInfo) => void): { dispose(): void } {
|
||||
this.exitListeners.add(listener);
|
||||
return {
|
||||
dispose: () => {
|
||||
this.exitListeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
emitData(data: string): void {
|
||||
for (const listener of this.dataListeners) {
|
||||
listener(data);
|
||||
}
|
||||
}
|
||||
|
||||
emitExit(event: TerminalExitInfo): void {
|
||||
for (const listener of this.exitListeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
async function createTempDirectory(): Promise<string> {
|
||||
const path = await mkdtemp(join(tmpdir(), 'aryx-pty-'));
|
||||
tempDirectories.push(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (tempDirectories.length > 0) {
|
||||
const path = tempDirectories.pop();
|
||||
if (path) {
|
||||
await rm(path, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('PtyManager', () => {
|
||||
test('prefers PowerShell on Windows when available', async () => {
|
||||
const cwd = await createTempDirectory();
|
||||
const spawnCalls: Array<{ file: string; args: string[]; options: { cwd: string } }> = [];
|
||||
const pty = new FakePty(101);
|
||||
const manager = new PtyManager({
|
||||
platform: 'win32',
|
||||
env: { SystemRoot: 'C:\\Windows' },
|
||||
commandExists: async (command) => command === 'pwsh.exe',
|
||||
spawnPty: async (file, args, options) => {
|
||||
spawnCalls.push({ file, args, options });
|
||||
return pty;
|
||||
},
|
||||
});
|
||||
|
||||
const snapshot = await manager.create(cwd);
|
||||
|
||||
expect(spawnCalls).toHaveLength(1);
|
||||
expect(spawnCalls[0]?.file).toBe('pwsh.exe');
|
||||
expect(spawnCalls[0]?.args).toEqual(['-NoLogo']);
|
||||
expect(spawnCalls[0]?.options.cwd).toBe(cwd);
|
||||
expect(snapshot).toEqual({
|
||||
cwd,
|
||||
shell: 'PowerShell',
|
||||
pid: 101,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
} satisfies TerminalSnapshot);
|
||||
});
|
||||
|
||||
test('forwards data, writes input, and tracks resized dimensions', async () => {
|
||||
const cwd = await createTempDirectory();
|
||||
const pty = new FakePty(202);
|
||||
const chunks: string[] = [];
|
||||
const manager = new PtyManager({
|
||||
platform: 'linux',
|
||||
env: { SHELL: '/bin/zsh', PATH: '/bin' },
|
||||
commandExists: async () => true,
|
||||
spawnPty: async () => pty,
|
||||
});
|
||||
|
||||
manager.on('data', (data) => {
|
||||
chunks.push(data);
|
||||
});
|
||||
|
||||
await manager.create(cwd);
|
||||
pty.emitData('$ ready');
|
||||
manager.write('npm test\r');
|
||||
manager.resize(120.2, 41.6);
|
||||
|
||||
expect(chunks).toEqual(['$ ready']);
|
||||
expect(pty.writes).toEqual(['npm test\r']);
|
||||
expect(pty.resizeCalls).toEqual([{ cols: 120, rows: 42 }]);
|
||||
expect(manager.getSnapshot()).toEqual({
|
||||
cwd,
|
||||
shell: 'zsh',
|
||||
pid: 202,
|
||||
cols: 120,
|
||||
rows: 42,
|
||||
} satisfies TerminalSnapshot);
|
||||
});
|
||||
|
||||
test('kills the active terminal, emits exit, and clears the snapshot', async () => {
|
||||
const cwd = await createTempDirectory();
|
||||
const exits: TerminalExitInfo[] = [];
|
||||
const pty = new FakePty(303);
|
||||
const manager = new PtyManager({
|
||||
platform: 'linux',
|
||||
env: { SHELL: '/bin/bash', PATH: '/bin' },
|
||||
commandExists: async () => true,
|
||||
spawnPty: async () => pty,
|
||||
});
|
||||
|
||||
manager.on('exit', (event) => {
|
||||
exits.push(event);
|
||||
});
|
||||
|
||||
await manager.create(cwd);
|
||||
manager.kill();
|
||||
|
||||
expect(exits).toEqual([{ exitCode: 0 }]);
|
||||
expect(manager.getSnapshot()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('restarts without forwarding the replaced terminal exit event', async () => {
|
||||
const cwd = await createTempDirectory();
|
||||
const exits: TerminalExitInfo[] = [];
|
||||
const ptys = [new FakePty(404), new FakePty(405)];
|
||||
let spawnIndex = 0;
|
||||
const manager = new PtyManager({
|
||||
platform: 'linux',
|
||||
env: { SHELL: '/bin/bash', PATH: '/bin' },
|
||||
commandExists: async () => true,
|
||||
spawnPty: async () => ptys[spawnIndex++]!,
|
||||
});
|
||||
|
||||
manager.on('exit', (event) => {
|
||||
exits.push(event);
|
||||
});
|
||||
|
||||
await manager.create(cwd);
|
||||
manager.resize(132, 36);
|
||||
const restarted = await manager.restart(cwd);
|
||||
|
||||
expect(ptys[0].killCalls).toBe(1);
|
||||
expect(exits).toEqual([]);
|
||||
expect(restarted).toEqual({
|
||||
cwd,
|
||||
shell: 'bash',
|
||||
pid: 405,
|
||||
cols: 132,
|
||||
rows: 36,
|
||||
} satisfies TerminalSnapshot);
|
||||
});
|
||||
|
||||
test('throws when the working directory does not exist', async () => {
|
||||
const manager = new PtyManager({
|
||||
platform: 'linux',
|
||||
env: { SHELL: '/bin/bash', PATH: '/bin' },
|
||||
commandExists: async () => true,
|
||||
spawnPty: async () => new FakePty(500),
|
||||
});
|
||||
|
||||
await expect(manager.create('C:\\workspace\\personal\\repositories\\aryx\\does-not-exist'))
|
||||
.rejects
|
||||
.toThrow('Terminal working directory');
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ const TIMESTAMP = '2026-03-23T00:00:00.000Z';
|
||||
describe('tooling settings helpers', () => {
|
||||
test('normalizes persisted MCP and LSP definitions into trimmed stable settings', () => {
|
||||
const workspaceSettings = normalizeWorkspaceSettings({
|
||||
terminalHeight: 240.4,
|
||||
tooling: {
|
||||
mcpServers: [
|
||||
{
|
||||
@@ -64,6 +65,7 @@ describe('tooling settings helpers', () => {
|
||||
|
||||
expect(workspaceSettings).toEqual({
|
||||
theme: 'dark',
|
||||
terminalHeight: 240,
|
||||
tooling: {
|
||||
mcpServers: [
|
||||
{
|
||||
@@ -109,6 +111,11 @@ describe('tooling settings helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('drops invalid persisted terminal height values', () => {
|
||||
expect(normalizeWorkspaceSettings({ terminalHeight: 119 }).terminalHeight).toBeUndefined();
|
||||
expect(normalizeWorkspaceSettings({ terminalHeight: Number.NaN }).terminalHeight).toBeUndefined();
|
||||
});
|
||||
|
||||
test('validates required MCP transport settings', () => {
|
||||
const localServer: McpServerDefinition = {
|
||||
id: 'mcp-local',
|
||||
|
||||
Reference in New Issue
Block a user