160 lines
9.2 KiB
TypeScript
160 lines
9.2 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { mkdtemp, mkdir, realpath, rm, symlink } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { logger } from '../../electron/utils/logger';
|
|
import { createPiManagedWorkerOpener, type PiWorkerProcessAdapter } from '../../electron/coding-runtime/pi/runtime';
|
|
import type { PiSessionRegistry } from '../../electron/coding-runtime/pi/session-registry';
|
|
import type { PiManagedExtensionHost } from '../../electron/coding-runtime/pi/extension-host';
|
|
import type { ModelToolRegistryPort } from '../../electron/coding-runtime/pi/model-tools/model-tool-registry';
|
|
import type { PiWorkerProcessOptions } from '../../electron/coding-runtime/pi/worker-process';
|
|
|
|
const roots: string[] = [];
|
|
const TITLE = 'private conversation title';
|
|
const PROMPT = 'private project instructions';
|
|
const SECRET = 'private-provider-credential';
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })));
|
|
});
|
|
|
|
async function fixture() {
|
|
const root = await mkdtemp(path.join(tmpdir(), 'makelore-init-diagnostic-'));
|
|
roots.push(root);
|
|
const projectPath = path.join(root, 'private-project');
|
|
await mkdir(projectPath);
|
|
const model = { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'off' as const };
|
|
const input = {
|
|
conversation: { conversationId: 'conversation-a', projectId: 'project-a', agentId: 'agent-a', title: TITLE,
|
|
model: { model, modelResolution: 'resolved' as const } },
|
|
generation: 7, revision: { provider: 1, resources: 1 },
|
|
};
|
|
const registry = {
|
|
prepare: vi.fn(async () => ({ projectPath,
|
|
conversation: { model, modelResolution: 'resolved', title: TITLE },
|
|
agent: { id: 'agent-a', name: 'private agent name', roleName: 'private role', prompt: PROMPT, skillIds: [] } })),
|
|
ensureBinding: vi.fn(async () => ({ session: { piSessionId: 'session-a', sessionKey: 'session-a' } })),
|
|
};
|
|
const dispose = vi.fn(async () => undefined);
|
|
const extensionHost = { registerWorker: vi.fn(async () => ({
|
|
allowedToolNames: [], extensionPath: path.join(root, 'extension.mjs'),
|
|
env: { PRIVATE_EXTENSION_SECRET: SECRET }, sensitiveValues: [SECRET], dispose,
|
|
})) };
|
|
const process = {
|
|
generation: 7, start: vi.fn(async () => undefined),
|
|
request: vi.fn(async () => ({ type: 'response', success: true, data: { sessionId: 'session-a' } })),
|
|
send: vi.fn(async () => undefined), subscribe: vi.fn(() => () => undefined),
|
|
subscribeInvalidation: vi.fn(() => () => undefined),
|
|
stop: vi.fn(async () => ({ mode: 'stdin-close' as const, code: 0, signal: null })),
|
|
};
|
|
const loadProviderInput = vi.fn(async () => ({ accounts: [{
|
|
id: 'account-a', vendorId: 'custom', label: 'fixture', authMode: 'api_key' as const,
|
|
apiProtocol: 'openai-completions' as const, baseUrl: 'https://provider.example/v1', model: 'model-a',
|
|
enabled: true, isDefault: true, createdAt: '2026-10-14T00:00:00Z', updatedAt: '2026-10-14T00:00:00Z',
|
|
}], modelSummaries: [] }));
|
|
const createProcess = vi.fn((_options: PiWorkerProcessOptions) => process as unknown as PiWorkerProcessAdapter);
|
|
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
const options = { registry: registry as unknown as PiSessionRegistry,
|
|
executablePath: 'electron', cliPath: 'pi-cli.js', userDataDir: path.join(root, 'user-data'),
|
|
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
|
extensionHost: extensionHost as unknown as PiManagedExtensionHost,
|
|
loadProviderInput, resolveCredential: async () => SECRET,
|
|
createSessionKey: () => 'session-a', createProcess };
|
|
const failures = () => warn.mock.calls.filter(([tag]) => tag === '[PiWorkerInitializationFailed]');
|
|
return { root, input, registry, extensionHost, dispose, process, loadProviderInput, createProcess, warn, failures, options };
|
|
}
|
|
|
|
describe('managed worker initialization failure logs', () => {
|
|
it('records an early registry failure once without changing its public error or logging private input', async () => {
|
|
const f = await fixture();
|
|
const error = Object.assign(new Error(`prompt=${PROMPT} token=${SECRET} path=${f.root}`), {
|
|
publicError: { code: 'CODING_SESSION_UNREADABLE', message: 'Safe public message' },
|
|
});
|
|
f.registry.prepare.mockRejectedValue(error);
|
|
await expect(createPiManagedWorkerOpener(f.options)(f.input)).rejects.toBe(error);
|
|
expect(error.publicError).toEqual({ code: 'CODING_SESSION_UNREADABLE', message: 'Safe public message' });
|
|
expect(f.loadProviderInput).not.toHaveBeenCalled();
|
|
expect(f.createProcess).not.toHaveBeenCalled();
|
|
expect(f.failures()).toHaveLength(1);
|
|
expect(f.failures()[0][1]).toMatchObject({ workerGeneration: 7, diagnostic: { stage: 'registry', exception: 'Error' } });
|
|
const log = JSON.stringify(f.failures());
|
|
for (const privateValue of [TITLE, PROMPT, SECRET, f.root, 'conversation-a']) expect(log).not.toContain(privateValue);
|
|
});
|
|
|
|
it('captures provider preparation failure before any worker exists', async () => {
|
|
const f = await fixture();
|
|
const error = new TypeError('Cannot read properties of undefined (reading "providers")');
|
|
f.loadProviderInput.mockRejectedValue(error);
|
|
await expect(createPiManagedWorkerOpener(f.options)(f.input)).rejects.toBe(error);
|
|
expect(f.createProcess).not.toHaveBeenCalled();
|
|
expect(f.failures()).toHaveLength(1);
|
|
expect(f.failures()[0][1]).toMatchObject({ diagnostic: { stage: 'provider-input', exception: 'TypeError' } });
|
|
});
|
|
|
|
it('retains code locations behind a dependency symlink without exposing its absolute path', async () => {
|
|
const f = await fixture();
|
|
const packageRoot = path.join(f.root, 'private-install', 'pi-package');
|
|
const packageLink = path.join(f.root, 'bundled-pi');
|
|
await mkdir(path.join(packageRoot, 'dist'), { recursive: true });
|
|
await symlink(packageRoot, packageLink, 'dir');
|
|
const actualPackageRoot = await realpath(packageRoot);
|
|
const error = new Error(SECRET);
|
|
Object.defineProperty(error, 'stack', {
|
|
configurable: true, value: `Error: ${SECRET}\n at load (${actualPackageRoot}/dist/session.js:22:4)`,
|
|
});
|
|
f.registry.prepare.mockRejectedValue(error);
|
|
await expect(createPiManagedWorkerOpener({ ...f.options,
|
|
cliPath: path.join(packageLink, 'dist', 'cli.js') })(f.input)).rejects.toBe(error);
|
|
expect(f.failures()).toHaveLength(1);
|
|
expect(f.failures()[0][1]).toMatchObject({ diagnostic: { frames: ['root3/dist/session.js:22:4'] } });
|
|
const log = JSON.stringify(f.failures());
|
|
for (const privateValue of [SECRET, f.root, packageRoot, actualPackageRoot, packageLink]) expect(log).not.toContain(privateValue);
|
|
});
|
|
|
|
it('logs initial state failure before cleanup, preserving the rejection and cleanup calls', async () => {
|
|
const f = await fixture();
|
|
const order: string[] = [];
|
|
const error = new TypeError(`secret=${SECRET} ${PROMPT} ${f.root}`);
|
|
f.warn.mockImplementation(() => { order.push('log'); });
|
|
f.process.request.mockRejectedValue(error);
|
|
f.process.stop.mockImplementation(async () => { order.push('stop'); return { mode: 'stdin-close', code: 0, signal: null }; });
|
|
f.dispose.mockImplementation(async () => { order.push('dispose'); });
|
|
await expect(createPiManagedWorkerOpener(f.options)(f.input)).rejects.toBe(error);
|
|
expect(f.process.start).toHaveBeenCalledOnce();
|
|
expect(f.process.stop).toHaveBeenCalledWith('open_failure');
|
|
expect(f.dispose).toHaveBeenCalledOnce();
|
|
expect(order).toEqual(['log', 'stop', 'dispose']);
|
|
expect(f.failures()).toHaveLength(1);
|
|
expect(f.failures()[0][1]).toMatchObject({ diagnostic: { stage: 'initial-hydration', exception: 'TypeError' } });
|
|
const log = JSON.stringify(f.failures());
|
|
for (const privateValue of [TITLE, PROMPT, SECRET, f.root]) expect(log).not.toContain(privateValue);
|
|
});
|
|
|
|
it('retains the original diagnostic when extension cleanup masks an initialization error', async () => {
|
|
const f = await fixture();
|
|
const original = new TypeError('Required extension function is not a function');
|
|
const cleanup = new RangeError('cleanup failure');
|
|
const modelDispose = vi.fn(() => { throw cleanup; });
|
|
f.extensionHost.registerWorker.mockRejectedValue(original);
|
|
const opener = createPiManagedWorkerOpener({ ...f.options,
|
|
modelToolRegistry: { registerWorker: () => ({ tools: [], dispose: modelDispose }) } as unknown as ModelToolRegistryPort });
|
|
await expect(opener(f.input)).rejects.toBe(cleanup);
|
|
expect(modelDispose).toHaveBeenCalledOnce();
|
|
expect(f.failures()).toHaveLength(1);
|
|
expect(f.failures()[0][1]).toMatchObject({ diagnostic: { stage: 'extension-registration', exception: 'TypeError' } });
|
|
expect(f.createProcess).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps the original initialization error even if diagnostic output throws', async () => {
|
|
const f = await fixture();
|
|
const original = new Error('original failure');
|
|
f.registry.prepare.mockRejectedValue(original);
|
|
f.warn.mockImplementation(() => { throw new Error('logger failed'); });
|
|
await expect(createPiManagedWorkerOpener(f.options)(f.input)).rejects.toBe(original);
|
|
expect(f.failures()).toHaveLength(1);
|
|
});
|
|
});
|