feat: add Pi process and RPC foundation
This commit is contained in:
128
tests/fixtures/fake-pi-rpc-child.mjs
vendored
Normal file
128
tests/fixtures/fake-pi-rpc-child.mjs
vendored
Normal file
@@ -0,0 +1,128 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
let input = '';
|
||||
let held = null;
|
||||
let keepAlive = null;
|
||||
let descendant = null;
|
||||
const counts = Object.create(null);
|
||||
|
||||
function response(command, data = {}) {
|
||||
return JSON.stringify({
|
||||
type: 'response',
|
||||
id: command.id,
|
||||
command: command.type,
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
function writeRecord(record) {
|
||||
process.stdout.write(`${typeof record === 'string' ? record : JSON.stringify(record)}\n`);
|
||||
}
|
||||
|
||||
function handle(command) {
|
||||
counts[command.type] = (counts[command.type] ?? 0) + 1;
|
||||
if (command.type === 'echo') {
|
||||
writeRecord(response(command, { value: command.value }));
|
||||
return;
|
||||
}
|
||||
if (command.type === 'emit_event') {
|
||||
writeRecord({ type: 'agent_start', marker: command.marker });
|
||||
writeRecord(response(command, { emitted: true }));
|
||||
return;
|
||||
}
|
||||
if (command.type === 'hold') {
|
||||
held = command;
|
||||
return;
|
||||
}
|
||||
if (command.type === 'release') {
|
||||
writeRecord(response(command, { order: 'first' }));
|
||||
if (held) {
|
||||
writeRecord(response(held, { order: 'second' }));
|
||||
held = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (command.type === 'partial') {
|
||||
const record = `${response(command, { partial: true })}\n`;
|
||||
process.stdout.write(record.slice(0, 7));
|
||||
setTimeout(() => process.stdout.write(record.slice(7, 19)), 5);
|
||||
setTimeout(() => process.stdout.write(record.slice(19)), 10);
|
||||
return;
|
||||
}
|
||||
if (command.type === 'malformed') {
|
||||
process.stdout.write('not-json\n');
|
||||
return;
|
||||
}
|
||||
if (command.type === 'blank') {
|
||||
process.stdout.write('\n');
|
||||
return;
|
||||
}
|
||||
if (command.type === 'invalid_utf8') {
|
||||
process.stdout.write(Buffer.from([0xff, 0x0a]));
|
||||
return;
|
||||
}
|
||||
if (command.type === 'trailing_partial') {
|
||||
process.stdout.write(response(command, { partial: true }));
|
||||
setTimeout(() => process.exit(0), 5);
|
||||
return;
|
||||
}
|
||||
if (command.type === 'large') {
|
||||
writeRecord(response(command, { value: 'x'.repeat(command.bytes ?? 4096) }));
|
||||
return;
|
||||
}
|
||||
if (command.type === 'get_state' && command.fakeRetry) {
|
||||
if (counts.get_state > 1) writeRecord(response(command, { attempts: counts.get_state }));
|
||||
return;
|
||||
}
|
||||
if (command.type === 'no_response' || command.type === 'prompt') return;
|
||||
if (command.type === 'stats') {
|
||||
writeRecord(response(command, { counts }));
|
||||
return;
|
||||
}
|
||||
if (command.type === 'stderr_secret') {
|
||||
const secret = process.env.FAKE_PI_SECRET ?? 'missing-secret';
|
||||
process.stderr.write(`${'diagnostic '.repeat(50)}\n`);
|
||||
process.stderr.write(`Authorization: Bearer ${secret}\n`);
|
||||
process.stderr.write(`token=${secret}\n`);
|
||||
writeRecord(response(command, { wrote: true }));
|
||||
return;
|
||||
}
|
||||
if (command.type === 'spawn_descendant') {
|
||||
descendant = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
keepAlive = setInterval(() => {}, 1000);
|
||||
writeRecord(response(command, { pid: descendant.pid }));
|
||||
return;
|
||||
}
|
||||
if (command.type === 'crash') {
|
||||
process.exit(7);
|
||||
}
|
||||
writeRecord({
|
||||
type: 'response',
|
||||
id: command.id,
|
||||
command: command.type,
|
||||
success: false,
|
||||
error: 'unsupported fake command',
|
||||
});
|
||||
}
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => {
|
||||
input += chunk;
|
||||
while (true) {
|
||||
const newline = input.indexOf('\n');
|
||||
if (newline === -1) break;
|
||||
const line = input.slice(0, newline).replace(/\r$/, '');
|
||||
input = input.slice(newline + 1);
|
||||
if (line) handle(JSON.parse(line));
|
||||
}
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
if (!keepAlive) process.exit(0);
|
||||
});
|
||||
process.on('exit', () => {
|
||||
if (keepAlive) clearInterval(keepAlive);
|
||||
});
|
||||
271
tests/unit/pi-rpc-foundation.test.ts
Normal file
271
tests/unit/pi-rpc-foundation.test.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { Writable } from 'node:stream';
|
||||
import { PiProcessError } from '../../electron/coding-runtime/pi/process-errors';
|
||||
import { PiRpcClient } from '../../electron/coding-runtime/pi/rpc-client';
|
||||
import { StrictLfJsonlFramer } from '../../electron/coding-runtime/pi/rpc-framer';
|
||||
import {
|
||||
PiWorkerProcess,
|
||||
buildPiRpcArgs,
|
||||
sanitizePiDiagnostic,
|
||||
} from '../../electron/coding-runtime/pi/worker-process';
|
||||
|
||||
const fakeChildPath = resolve('tests/fixtures/fake-pi-rpc-child.mjs');
|
||||
const scratchRoots: string[] = [];
|
||||
const workers: PiWorkerProcess[] = [];
|
||||
|
||||
async function makeWorker(
|
||||
options: Partial<ConstructorParameters<typeof PiWorkerProcess>[0]> = {},
|
||||
): Promise<PiWorkerProcess> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'makelore-pi-rpc-test-'));
|
||||
scratchRoots.push(root);
|
||||
const configDir = join(root, 'config');
|
||||
const sessionDir = join(root, 'sessions');
|
||||
const cwd = join(root, 'project');
|
||||
await Promise.all([
|
||||
mkdir(configDir),
|
||||
mkdir(sessionDir),
|
||||
mkdir(cwd),
|
||||
]);
|
||||
const worker = new PiWorkerProcess({
|
||||
executablePath: process.execPath,
|
||||
cliPath: fakeChildPath,
|
||||
cwd,
|
||||
configDir,
|
||||
sessionDir,
|
||||
commandTimeoutMs: 1_000,
|
||||
shutdownGraceMs: 500,
|
||||
...options,
|
||||
});
|
||||
workers.push(worker);
|
||||
return await worker.start();
|
||||
}
|
||||
|
||||
async function processAlive(pid: number): Promise<boolean> {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(workers.splice(0).map((worker) => worker.stop().catch(() => undefined)));
|
||||
await Promise.all(scratchRoots.splice(0).map((root) => rm(root, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 3,
|
||||
})));
|
||||
});
|
||||
|
||||
describe('strict Pi LF JSONL framing', () => {
|
||||
it('handles chunk boundaries, multiple records, CRLF, and Unicode separators', () => {
|
||||
const records: unknown[] = [];
|
||||
const framer = new StrictLfJsonlFramer({ onRecord: (record) => records.push(record) });
|
||||
const source = Buffer.from(
|
||||
`${JSON.stringify({ text: 'left\u2028middle\u2029right' })}\n${JSON.stringify({ ok: true })}\r\n`,
|
||||
);
|
||||
|
||||
framer.push(source.subarray(0, 8));
|
||||
framer.push(source.subarray(8, 23));
|
||||
framer.push(source.subarray(23));
|
||||
framer.finish();
|
||||
|
||||
expect(records).toEqual([
|
||||
{ text: 'left\u2028middle\u2029right' },
|
||||
{ ok: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails closed for malformed, blank, invalid UTF-8, oversized, and partial records', () => {
|
||||
const make = (maxLineBytes = 64) => new StrictLfJsonlFramer({
|
||||
maxLineBytes,
|
||||
onRecord: () => undefined,
|
||||
});
|
||||
|
||||
expect(() => make().push('not-json\n')).toThrow(/malformed JSON/);
|
||||
expect(() => make().push('\n')).toThrow(/blank line/);
|
||||
expect(() => make().push(Buffer.from([0xff, 0x0a]))).toThrow(/valid UTF-8/);
|
||||
expect(() => make(4).push('12345')).toThrow(/exceeded 4 bytes/);
|
||||
const partial = make();
|
||||
partial.push('{"ok":true}');
|
||||
expect(() => partial.finish()).toThrow(/partial line/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pi RPC client', () => {
|
||||
it('waits for writable completion when the stream applies backpressure', async () => {
|
||||
let written = '';
|
||||
let flush: (() => void) | undefined;
|
||||
const writable = new Writable({
|
||||
highWaterMark: 1,
|
||||
write(chunk, _encoding, callback) {
|
||||
written += chunk.toString();
|
||||
flush = callback;
|
||||
},
|
||||
});
|
||||
const client = new PiRpcClient(writable, { generation: 3, defaultTimeoutMs: 500 });
|
||||
let settled = false;
|
||||
const requested = client.request({ type: 'get_state' }).then((response) => {
|
||||
settled = true;
|
||||
return response;
|
||||
});
|
||||
await new Promise((resolvePromise) => setImmediate(resolvePromise));
|
||||
const command = JSON.parse(written) as { id: string };
|
||||
client.accept({ type: 'response', id: command.id, success: true, data: { ready: true } });
|
||||
await new Promise((resolvePromise) => setImmediate(resolvePromise));
|
||||
expect(settled).toBe(false);
|
||||
flush?.();
|
||||
|
||||
await expect(requested).resolves.toMatchObject({ data: { ready: true } });
|
||||
});
|
||||
|
||||
it('rejects retry policy for prompt-like mutation commands', async () => {
|
||||
const writable = new Writable({ write(_chunk, _encoding, callback) { callback(); } });
|
||||
const client = new PiRpcClient(writable, { generation: 1 });
|
||||
|
||||
await expect(client.request(
|
||||
{ type: 'prompt', message: 'do not replay' },
|
||||
{ retry: 'read-only-once' },
|
||||
)).rejects.toThrow(/not a retryable read-only command/);
|
||||
await expect(client.request(
|
||||
{ type: 'set_model', provider: 'provider-a', modelId: 'model-a' },
|
||||
{ retry: 'read-only-once' },
|
||||
)).rejects.toThrow(/not a retryable read-only command/);
|
||||
});
|
||||
|
||||
it('settles and removes the pending command when the writable fails', async () => {
|
||||
const writable = new Writable({
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback(new Error('closed pipe'));
|
||||
},
|
||||
});
|
||||
const client = new PiRpcClient(writable, { generation: 1 });
|
||||
|
||||
await expect(client.request({ type: 'get_state' }))
|
||||
.rejects.toMatchObject({ code: 'PI_RPC_WRITE_FAILED' });
|
||||
expect(client.pendingCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pi worker process', () => {
|
||||
it('uses the locked offline and no-discovery RPC arguments', () => {
|
||||
expect(buildPiRpcArgs('sessions', ['--model', 'model-a'])).toEqual([
|
||||
'--mode', 'rpc',
|
||||
'--offline',
|
||||
'--session-dir', 'sessions',
|
||||
'--no-extensions',
|
||||
'--no-skills',
|
||||
'--no-prompt-templates',
|
||||
'--no-themes',
|
||||
'--no-context-files',
|
||||
'--no-approve',
|
||||
'--no-tools',
|
||||
'--model', 'model-a',
|
||||
]);
|
||||
});
|
||||
|
||||
it('correlates out-of-order responses, dispatches events, and reassembles partial lines', async () => {
|
||||
const worker = await makeWorker();
|
||||
const events: unknown[] = [];
|
||||
worker.subscribe(() => { throw new Error('consumer failed'); });
|
||||
worker.subscribe((event) => events.push(event));
|
||||
const held = worker.request<{ order: string }>({ type: 'hold' });
|
||||
const released = worker.request<{ order: string }>({ type: 'release' });
|
||||
|
||||
await expect(released).resolves.toMatchObject({ data: { order: 'first' } });
|
||||
await expect(held).resolves.toMatchObject({ data: { order: 'second' } });
|
||||
await expect(worker.request({ type: 'emit_event', marker: 'event-a' }))
|
||||
.resolves.toMatchObject({ data: { emitted: true } });
|
||||
await expect(worker.request({ type: 'partial' }))
|
||||
.resolves.toMatchObject({ data: { partial: true } });
|
||||
expect(events).toContainEqual({ type: 'agent_start', marker: 'event-a' });
|
||||
expect(worker.stderrDiagnostic).toContain('[event-listener] consumer failed');
|
||||
await expect(worker.request({ type: 'echo', value: 'listener-isolated' }))
|
||||
.resolves.toMatchObject({ data: { value: 'listener-isolated' } });
|
||||
});
|
||||
|
||||
it('settles timeout and abort without replaying prompt, while read-only retry runs once', async () => {
|
||||
const worker = await makeWorker({ commandTimeoutMs: 500 });
|
||||
await expect(worker.request(
|
||||
{ type: 'get_state', fakeRetry: true },
|
||||
{ retry: 'read-only-once', timeoutMs: 500 },
|
||||
)).resolves.toMatchObject({ data: { attempts: 2 } });
|
||||
|
||||
await expect(worker.request(
|
||||
{ type: 'prompt', message: 'one attempt' },
|
||||
{ timeoutMs: 100 },
|
||||
)).rejects.toMatchObject({ code: 'PI_RPC_TIMEOUT' });
|
||||
const stats = await worker.request<{ counts: Record<string, number> }>({ type: 'stats' });
|
||||
expect(stats.data?.counts.prompt).toBe(1);
|
||||
|
||||
const controller = new AbortController();
|
||||
const pending = worker.request({ type: 'no_response' }, { signal: controller.signal });
|
||||
const aborted = expect(pending).rejects.toMatchObject({ code: 'PI_RPC_ABORTED' });
|
||||
controller.abort();
|
||||
await aborted;
|
||||
expect(worker.pendingCommandCount).toBe(0);
|
||||
});
|
||||
|
||||
it.each(['malformed', 'blank', 'invalid_utf8', 'trailing_partial', 'large'])(
|
||||
'invalidates only the target worker for %s stdout',
|
||||
async (failureType) => {
|
||||
const left = await makeWorker({ maxLineBytes: 128 });
|
||||
const right = await makeWorker({ maxLineBytes: 128 });
|
||||
const command = failureType === 'large'
|
||||
? { type: failureType, bytes: 512 }
|
||||
: { type: failureType };
|
||||
|
||||
await expect(left.request(command)).rejects.toMatchObject({ code: 'PI_RPC_PROTOCOL_ERROR' });
|
||||
expect(left.generation).toBe(2);
|
||||
expect(left.protocolError).toBeInstanceOf(PiProcessError);
|
||||
expect(left.protocolError?.diagnostic).toContain('[stdout-protocol]');
|
||||
await expect(right.request({ type: 'echo', value: 'still-alive' }))
|
||||
.resolves.toMatchObject({ data: { value: 'still-alive' } });
|
||||
},
|
||||
);
|
||||
|
||||
it('settles every pending command after an unexpected exit', async () => {
|
||||
const worker = await makeWorker();
|
||||
const pending = worker.request({ type: 'no_response' });
|
||||
const crash = worker.request({ type: 'crash' });
|
||||
|
||||
await expect(Promise.all([pending, crash])).rejects.toMatchObject({ code: 'PI_RPC_EXITED' });
|
||||
expect(worker.pendingCommandCount).toBe(0);
|
||||
expect(worker.generation).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps only bounded redacted stderr diagnostics', async () => {
|
||||
const secret = 'credential-that-must-not-leak';
|
||||
const worker = await makeWorker({
|
||||
env: { FAKE_PI_SECRET: secret },
|
||||
sensitiveValues: [secret],
|
||||
diagnosticBytes: 160,
|
||||
});
|
||||
|
||||
await worker.request({ type: 'stderr_secret' });
|
||||
await new Promise((resolvePromise) => setTimeout(resolvePromise, 10));
|
||||
expect(worker.stderrDiagnostic).not.toContain(secret);
|
||||
expect(worker.stderrDiagnostic).toContain('[REDACTED]');
|
||||
expect(Buffer.byteLength(worker.stderrDiagnostic)).toBeLessThanOrEqual(160);
|
||||
expect(sanitizePiDiagnostic(`token=${secret}`, [secret])).toBe('token=[REDACTED]');
|
||||
});
|
||||
|
||||
it('forces the complete child tree down after the graceful deadline', async () => {
|
||||
const worker = await makeWorker({ shutdownGraceMs: 100 });
|
||||
const response = await worker.request<{ pid: number }>({ type: 'spawn_descendant' });
|
||||
const descendantPid = response.data?.pid;
|
||||
expect(descendantPid).toBeTypeOf('number');
|
||||
|
||||
await expect(worker.stop()).resolves.toMatchObject({ mode: 'forced-tree-kill' });
|
||||
for (let attempt = 0; attempt < 20 && await processAlive(descendantPid!); attempt += 1) {
|
||||
await new Promise((resolvePromise) => setTimeout(resolvePromise, 25));
|
||||
}
|
||||
expect(await processAlive(descendantPid!)).toBe(false);
|
||||
});
|
||||
});
|
||||
137
tests/unit/pi-runtime-bundle.test.ts
Normal file
137
tests/unit/pi-runtime-bundle.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import YAML from 'yaml';
|
||||
import {
|
||||
assertPackageIdentities,
|
||||
installProductionShrinkwrap,
|
||||
preparePublishedPackageRoot,
|
||||
requiredPackageIdentities,
|
||||
} from '../../scripts/lib/pi-runtime-bundle.mjs';
|
||||
import { parseBundleArgs } from '../../scripts/bundle-pi-runtime.mjs';
|
||||
|
||||
const scratchRoots: string[] = [];
|
||||
|
||||
async function scratch(prefix: string): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), prefix));
|
||||
scratchRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(scratchRoots.splice(0).map((root) => rm(root, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 3,
|
||||
})));
|
||||
});
|
||||
|
||||
describe('Pi runtime production bundler', () => {
|
||||
it('turns the published root dev mismatch into a reproducible npm 11 production install', async () => {
|
||||
const root = await scratch('makelore-pi-bundle-mismatch-');
|
||||
const source = join(root, 'published');
|
||||
const stage = join(root, 'stage');
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, 'package.json'), `${JSON.stringify({
|
||||
name: '@fixture/pi-runtime',
|
||||
version: '1.0.0',
|
||||
devDependencies: { 'missing-from-production-lock': '1.0.0' },
|
||||
}, null, 2)}\n`);
|
||||
await writeFile(join(source, 'npm-shrinkwrap.json'), `${JSON.stringify({
|
||||
name: '@fixture/pi-runtime',
|
||||
version: '1.0.0',
|
||||
lockfileVersion: 3,
|
||||
requires: true,
|
||||
packages: {
|
||||
'': { name: '@fixture/pi-runtime', version: '1.0.0' },
|
||||
},
|
||||
}, null, 2)}\n`);
|
||||
|
||||
const prepared = await preparePublishedPackageRoot(source, stage);
|
||||
expect(prepared.omittedDevDependencies).toEqual(['missing-from-production-lock']);
|
||||
expect(JSON.parse(await readFile(join(stage, 'package.json'), 'utf8')))
|
||||
.not.toHaveProperty('devDependencies');
|
||||
await expect(installProductionShrinkwrap({
|
||||
projectRoot: process.cwd(),
|
||||
stageRoot: stage,
|
||||
target: { platform: process.platform, arch: process.arch },
|
||||
})).resolves.toMatchObject({});
|
||||
}, 15_000);
|
||||
|
||||
it('accepts same-version nested-to-root flattening by identity instead of physical lock path', () => {
|
||||
const shrinkwrap = {
|
||||
packages: {
|
||||
'node_modules/p-retry/node_modules/@types/retry': {
|
||||
version: '0.12.0',
|
||||
},
|
||||
},
|
||||
};
|
||||
const required = requiredPackageIdentities(shrinkwrap, {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
});
|
||||
|
||||
expect(required.identities).toEqual(['@types/retry@0.12.0']);
|
||||
expect(() => assertPackageIdentities(required.identities, [
|
||||
'@types/retry@0.12.0',
|
||||
])).not.toThrow();
|
||||
});
|
||||
|
||||
it('keeps target selection explicit and rejects cross-OS staging', () => {
|
||||
const current = `${process.platform}-${process.arch}`;
|
||||
expect(parseBundleArgs(['--target', current]).targets).toEqual([
|
||||
expect.objectContaining({ platform: process.platform, arch: process.arch }),
|
||||
]);
|
||||
const foreign = process.platform === 'win32' ? 'linux-x64' : 'win32-x64';
|
||||
expect(() => parseBundleArgs(['--target', foreign])).toThrow(/must be staged on/);
|
||||
expect(() => parseBundleArgs(['--target', 'unknown-x64'])).toThrow(/Unsupported/);
|
||||
});
|
||||
|
||||
it('wires every package target to its matching staged Pi runtime', async () => {
|
||||
const packageJson = JSON.parse(await readFile('package.json', 'utf8')) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
const builder = YAML.parse(await readFile('electron-builder.yml', 'utf8')) as Record<
|
||||
string,
|
||||
{ extraResources?: Array<{ from: string; to: string }> }
|
||||
>;
|
||||
|
||||
expect(packageJson.scripts.build).toContain('bundle-pi-runtime.mjs --release-targets');
|
||||
expect(packageJson.scripts.package).toContain('bundle-pi-runtime.mjs --release-targets');
|
||||
expect(packageJson.scripts['package:stage:win-x64'])
|
||||
.toContain('bundle-pi-runtime.mjs --target win32-x64');
|
||||
expect(builder.mac.extraResources).toContainEqual({
|
||||
from: 'build/pi-runtime/darwin-${arch}',
|
||||
to: 'pi-runtime',
|
||||
filter: ['**/*', '!node_modules{,/**/*}'],
|
||||
});
|
||||
expect(builder.mac.extraResources).toContainEqual({
|
||||
from: 'build/pi-runtime/darwin-${arch}/node_modules',
|
||||
to: 'pi-runtime/node_modules',
|
||||
filter: ['**/*'],
|
||||
});
|
||||
expect(builder.win.extraResources).toContainEqual({
|
||||
from: 'build/pi-runtime/win32-${arch}',
|
||||
to: 'pi-runtime',
|
||||
filter: ['**/*', '!node_modules{,/**/*}'],
|
||||
});
|
||||
expect(builder.win.extraResources).toContainEqual({
|
||||
from: 'build/pi-runtime/win32-${arch}/node_modules',
|
||||
to: 'pi-runtime/node_modules',
|
||||
filter: ['**/*'],
|
||||
});
|
||||
expect(builder.linux.extraResources).toContainEqual({
|
||||
from: 'build/pi-runtime/linux-${arch}',
|
||||
to: 'pi-runtime',
|
||||
filter: ['**/*', '!node_modules{,/**/*}'],
|
||||
});
|
||||
expect(builder.linux.extraResources).toContainEqual({
|
||||
from: 'build/pi-runtime/linux-${arch}/node_modules',
|
||||
to: 'pi-runtime/node_modules',
|
||||
filter: ['**/*'],
|
||||
});
|
||||
});
|
||||
});
|
||||
61
tests/unit/pi-worker-process-real.test.ts
Normal file
61
tests/unit/pi-worker-process-real.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { PiWorkerProcess } from '../../electron/coding-runtime/pi/worker-process';
|
||||
|
||||
const scratchRoots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(scratchRoots.splice(0).map((root) => rm(root, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 3,
|
||||
})));
|
||||
});
|
||||
|
||||
describe('locked Pi worker process smoke', () => {
|
||||
it('loads the real Pi 0.84.2 entry through Electron Node and exits by closing stdin', async () => {
|
||||
const requireFromProject = createRequire(resolve('package.json'));
|
||||
const electronExecutable = requireFromProject('electron') as string;
|
||||
const packageRoot = realpathSync(resolve(
|
||||
'node_modules',
|
||||
'@earendil-works',
|
||||
'pi-coding-agent',
|
||||
));
|
||||
const packageJson = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) as {
|
||||
version: string;
|
||||
};
|
||||
expect(packageJson.version).toBe('0.84.2');
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), 'makelore-pi-real-worker-'));
|
||||
scratchRoots.push(root);
|
||||
const configDir = join(root, 'config');
|
||||
const sessionDir = join(root, 'sessions');
|
||||
const cwd = join(root, 'project');
|
||||
await Promise.all([mkdir(configDir), mkdir(sessionDir), mkdir(cwd)]);
|
||||
|
||||
const worker = await new PiWorkerProcess({
|
||||
executablePath: electronExecutable,
|
||||
cliPath: join(packageRoot, 'dist', 'cli.js'),
|
||||
cwd,
|
||||
configDir,
|
||||
sessionDir,
|
||||
commandTimeoutMs: 5_000,
|
||||
}).start();
|
||||
try {
|
||||
await expect(worker.request({ type: 'get_state' })).resolves.toMatchObject({
|
||||
type: 'response',
|
||||
command: 'get_state',
|
||||
success: true,
|
||||
});
|
||||
await expect(worker.stop()).resolves.toMatchObject({ mode: 'stdin-close', code: 0 });
|
||||
} finally {
|
||||
await worker.stop().catch(() => undefined);
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
Reference in New Issue
Block a user