Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 1 addition & 7 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,6 @@ jobs:
with:
install: true
- run: bun install --frozen-lockfile
- run: npm install -g @earendil-works/pi-coding-agent
- run: bunx tsc -p tsconfig.main.json
- run: bunx tsc -p tsconfig.preload.json
- run: bunx vite build
- run: mise run dev:vite &
- run: bunx wait-on http://localhost:5173
- run: mise run test:e2e
- name: upload test artefacts
if: always()
Expand All @@ -31,4 +25,4 @@ jobs:
path: |
/tmp/pidex-*.png
test-results/
if-no-files-found: ignore
if-no-files-found: ignore
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ $ mise run dev # starts Vite + Electron

For a production build: `mise run build` then `mise run start`. Native installers: `mise run package`.

### Tests

```bash
$ bun run test:unit # parsers, session archives, workspace and skill discovery
$ bun run test:integration # React component behavior in jsdom
$ bun run test:e2e # real Electron UI on macOS
```

The E2E suite compiles main/preload, starts an isolated renderer server on a free port, and creates temporary Git workspaces, sessions, and app settings. It uses a deterministic Pi RPC fixture, so it needs no provider credentials. Screenshots are written to `test-results/workbench/`. Every PR runs all three test lanes; `mise run build` also runs lint, typecheck, unit and component tests.

## Architecture

```
Expand Down
109 changes: 92 additions & 17 deletions src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { app, BrowserWindow, dialog, ipcMain } from 'electron';
import fs from 'fs';
import path from 'path';
import { listSessions, listWorkspaces, SESSIONS_ROOT } from './sessions';
import { listSessions, listWorkspaces } from './sessions';
import { sessionPath } from './workbench';
import { RpcSession } from './rpc';
import { registerWorkbench, sessionDefaults } from './workbench-ipc';

const isDev = !app.isPackaged;
if (isDev && process.env.PIDEX_E2E_USER_DATA)
app.setPath('userData', process.env.PIDEX_E2E_USER_DATA);
const sessions = new Map<string, RpcSession>();
let sessionSeq = 0;
let mainWindow: BrowserWindow | null = null;
Expand All @@ -14,10 +18,13 @@ function customWorkspacesFile(): string {
}

function readCustomWorkspaces(): string[] {
if (process.env.PIDEX_E2E_CWD) return [process.env.PIDEX_E2E_CWD];
try {
const raw = fs.readFileSync(customWorkspacesFile(), 'utf8');
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((p): p is string => typeof p === 'string') : [];
return Array.isArray(parsed)
? parsed.filter((p): p is string => typeof p === 'string')
: [];
} catch {
return [];
}
Expand All @@ -26,7 +33,10 @@ function readCustomWorkspaces(): string[] {
function addCustomWorkspace(cwd: string): void {
const existing = readCustomWorkspaces();
if (!existing.includes(cwd)) {
fs.writeFileSync(customWorkspacesFile(), JSON.stringify([...existing, cwd], null, 2));
fs.writeFileSync(
customWorkspacesFile(),
JSON.stringify([...existing, cwd], null, 2),
);
}
}

Expand All @@ -53,8 +63,12 @@ function createWindow() {
});

if (isDev) {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools({ mode: 'detach' });
mainWindow.loadURL(
(process.env.PIDEX_E2E_CWD && process.env.PIDEX_E2E_RENDERER_URL) ||
'http://localhost:5173',
);
if (!process.env.PIDEX_E2E_CWD)
mainWindow.webContents.openDevTools({ mode: 'detach' });
} else {
mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'));
}
Expand All @@ -64,15 +78,20 @@ function createWindow() {
});
}

ipcMain.handle('pidex:workspaces', () => listWorkspaces(readCustomWorkspaces()));
ipcMain.handle('pidex:workspaces', () =>
listWorkspaces(readCustomWorkspaces()),
);

registerWorkbench(() => mainWindow, readCustomWorkspaces, sessions);

ipcMain.handle('pidex:workspace:add', async () => {
if (!mainWindow) return { error: 'no window' };
const result = await dialog.showOpenDialog(mainWindow, {
title: 'Add workspace',
properties: ['openDirectory', 'createDirectory'],
});
if (result.canceled || result.filePaths.length === 0) return { error: 'cancelled' };
if (result.canceled || result.filePaths.length === 0)
return { error: 'cancelled' };
const cwd = result.filePaths[0];
addCustomWorkspace(cwd);
return { cwd };
Expand All @@ -85,14 +104,21 @@ ipcMain.handle('pidex:sessions', (_e, dirName: string) => {

ipcMain.handle(
'pidex:session/open',
async (_e, req: { cwd: string; sessionFile?: string }) => {
async (_e, req: { cwd: string; sessionFile?: string; fork?: boolean }) => {
const cwd = typeof req?.cwd === 'string' ? req.cwd : '';
const sessionFile = typeof req?.sessionFile === 'string' ? req.sessionFile : undefined;
const sessionFile =
typeof req?.sessionFile === 'string' ? req.sessionFile : undefined;
if (!cwd || !fs.existsSync(cwd)) {
return { error: 'workspace path does not exist' };
}
if (sessionFile && !path.resolve(sessionFile).startsWith(SESSIONS_ROOT)) {
return { error: 'session file outside pi sessions root' };
if (sessionFile) {
try {
await sessionPath(sessionFile);
} catch {
return {
error: 'session file outside pi sessions root or unavailable',
};
}
}

const id = `s${++sessionSeq}`;
Expand All @@ -103,11 +129,55 @@ ipcMain.handle(
const message = err instanceof Error ? err.message : 'could not start pi';
return { error: message };
}
session.onEvent = (sessionId, event) => sendToRenderer('pidex:session:event', { sessionId, event });
session.onExit = sessionId => sendToRenderer('pidex:session:exit', { sessionId });
session.onEvent = (sessionId, event) =>
sendToRenderer('pidex:session:event', { sessionId, event });
session.onExit = (sessionId) =>
sendToRenderer('pidex:session:exit', { sessionId });
sessions.set(id, session);

const [state, messages] = await Promise.all([session.getState(), session.getMessages()]);
if (req.fork && sessionFile) {
const cloned = await session.command({ type: 'clone' });
if (
!cloned.success ||
(cloned.data as { cancelled?: boolean } | undefined)?.cancelled
) {
sessions.delete(id);
session.dispose();
return { error: cloned.error ?? 'Session fork was cancelled.' };
}
}

if (!sessionFile) {
const defaults = await sessionDefaults(cwd);
if (defaults.model && defaults.provider) {
const changed = await session.command({
type: 'set_model',
provider: defaults.provider,
modelId: defaults.model,
});
if (!changed.success) {
sessions.delete(id);
session.dispose();
return {
error: changed.error ?? 'Could not apply the default model.',
};
}
}
const thinking = await session.command({
type: 'set_thinking_level',
level: defaults.thinking,
});
if (!thinking.success) {
sessions.delete(id);
session.dispose();
return { error: thinking.error ?? 'Could not apply reasoning effort.' };
}
}

const [state, messages] = await Promise.all([
session.getState(),
session.getMessages(),
]);
const launchError = session.launchError();
if (launchError) {
sessions.delete(id);
Expand All @@ -117,7 +187,9 @@ ipcMain.handle(
return {
sessionId: id,
state: state.success ? state.data : null,
messages: messages.success ? (messages.data as { messages?: unknown[] })?.messages ?? [] : [],
messages: messages.success
? ((messages.data as { messages?: unknown[] })?.messages ?? [])
: [],
};
},
);
Expand All @@ -127,7 +199,10 @@ ipcMain.handle(
async (_e, req: { sessionId: string; text: string; streaming: boolean }) => {
const session = sessions.get(req?.sessionId ?? '');
if (!session) return { error: 'no such session' };
const response = await session.prompt(String(req.text ?? ''), Boolean(req.streaming));
const response = await session.prompt(
String(req.text ?? ''),
Boolean(req.streaming),
);
return { success: response.success, error: response.error };
},
);
Expand All @@ -141,7 +216,7 @@ ipcMain.handle('pidex:session/commands', async (_e, sessionId: string) => {
if (!session) return { commands: [] };
const response = await session.getCommands();
const data = response.data as { commands?: unknown[] } | undefined;
return { commands: response.success ? data?.commands ?? [] : [] };
return { commands: response.success ? (data?.commands ?? []) : [] };
});

ipcMain.handle('pidex:session/close', (_e, sessionId: string) => {
Expand Down
45 changes: 34 additions & 11 deletions src/main/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import fs from 'fs';
import os from 'os';
import path from 'path';

const PI_MISSING = 'Could not find the pi command. Install it, or set PIDEX_PI to the binary.';
const PI_MISSING =
'Could not find the pi command. Install it, or set PIDEX_PI to the binary.';

type RpcEvent = Record<string, unknown> & { type: string };

Expand Down Expand Up @@ -63,7 +64,7 @@ export function resolvePi(

function childEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const current = (env.PATH ?? '').split(path.delimiter).filter(Boolean);
const extra = piPathDirs().filter(dir => !current.includes(dir));
const extra = piPathDirs().filter((dir) => !current.includes(dir));
return { ...env, PATH: [...extra, ...current].join(path.delimiter) };
}

Expand All @@ -75,16 +76,25 @@ class RpcSession {
private seq = 0;
private closed = false;
private failed: string | null = null;
private pending = new Map<string, { resolve: (r: RpcResponse) => void; timer: NodeJS.Timeout }>();
private pending = new Map<
string,
{ resolve: (r: RpcResponse) => void; timer: NodeJS.Timeout }
>();
onEvent: (sessionId: string, event: RpcEvent) => void = () => {};
onExit: (sessionId: string, code: number | null) => void = () => {};

constructor(id: string, cwd: string, sessionFile?: string) {
constructor(
id: string,
cwd: string,
sessionFile?: string,
ephemeral = false,
) {
this.id = id;
this.cwd = cwd;
const bin = resolvePi();
if (!bin) throw new Error(PI_MISSING);
const args = ['--mode', 'rpc'];
if (ephemeral) args.push('--no-session');
if (sessionFile) args.push('--session', sessionFile);
this.proc = spawn(bin, args, {
cwd,
Expand All @@ -93,16 +103,16 @@ class RpcSession {
});

this.proc.stdout!.setEncoding('utf8');
this.proc.stdout!.on('data', chunk => this.handleChunk(chunk));
this.proc.stdout!.on('data', (chunk) => this.handleChunk(chunk));
this.proc.stderr!.setEncoding('utf8');
this.proc.stderr!.on('data', () => {});
this.proc.stdin?.on('error', () => {});
this.proc.on('error', err => {
this.proc.on('error', (err) => {
const code = (err as NodeJS.ErrnoException).code;
this.failed = code === 'ENOENT' ? PI_MISSING : err.message;
this.close(null);
});
this.proc.on('exit', code => {
this.proc.on('exit', (code) => {
this.failed ??= 'process exited';
this.close(code);
});
Expand Down Expand Up @@ -160,19 +170,32 @@ class RpcSession {
});
}
const id = `req-${++this.seq}`;
return new Promise(resolve => {
return new Promise((resolve) => {
const timer = setTimeout(() => {
this.pending.delete(id);
resolve({ type: 'response', command: String(cmd.type ?? ''), success: false, id, error: 'timeout' });
resolve({
type: 'response',
command: String(cmd.type ?? ''),
success: false,
id,
error: 'timeout',
});
}, 30000);
this.pending.set(id, { resolve, timer });
try {
this.proc.stdin!.write(JSON.stringify({ id, ...cmd }) + '\n');
} catch (err) {
clearTimeout(timer);
this.pending.delete(id);
const message = err instanceof Error ? err.message : 'could not write to pi';
resolve({ type: 'response', command: String(cmd.type ?? ''), success: false, id, error: message });
const message =
err instanceof Error ? err.message : 'could not write to pi';
resolve({
type: 'response',
command: String(cmd.type ?? ''),
success: false,
id,
error: message,
});
}
});
}
Expand Down
Loading
Loading