Files
makelore/tests/unit/pi-initialization-failure-logging.test.ts
T

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);
});
});