feat(coding): compose plugin host lifecycle
This commit is contained in:
130
tests/unit/coding-plugin-composition.test.ts
Normal file
130
tests/unit/coding-plugin-composition.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createCodingProjectPluginService,
|
||||
} from '../../electron/api/coding-product-services';
|
||||
import { createCodingProjectMetadata, createCodingProjectAgent } from '../../electron/coding-projects/project-config';
|
||||
import type { CodingPluginAdapter } from '../../electron/coding-plugins/registry';
|
||||
import { createProjectPluginService } from '../../electron/coding-plugins/project-service';
|
||||
import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins';
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('coding plugin bounded product service', () => {
|
||||
it('joins package and policy exactly while isolating adapter inspection failure', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-product-'));
|
||||
roots.push(root);
|
||||
await createCodingProjectMetadata(root, {
|
||||
projectId: '11111111-1111-4111-8111-111111111111',
|
||||
now: '2026-08-27T00:00:00.000Z',
|
||||
});
|
||||
await createCodingProjectAgent(root, {
|
||||
id: 'builder', avatarId: 'avatar-01', roleName: '实现者', name: 'Builder',
|
||||
model: null, modelResolution: 'required', skillIds: ['data-service'],
|
||||
responsibility: { mission: 'Build', owns: [], boundaries: [], collaborators: [], principles: [] },
|
||||
});
|
||||
const projectPlugins = createProjectPluginService({ now: () => '2026-08-27T00:00:00.000Z' });
|
||||
await projectPlugins.enable(root, DATA_SERVICE_PLUGIN_DEFINITION.id);
|
||||
const adapter: CodingPluginAdapter = {
|
||||
pluginId: DATA_SERVICE_PLUGIN_DEFINITION.id,
|
||||
inspect: vi.fn().mockRejectedValue(new Error('secret upstream body')),
|
||||
invoke: vi.fn(),
|
||||
};
|
||||
const policyClient = {
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
getState: () => ({
|
||||
status: 'current' as const,
|
||||
revision: 1,
|
||||
lastVerifiedAt: 1,
|
||||
catalog: {
|
||||
schema_version: 1 as const,
|
||||
catalog_version: 'catalog-a',
|
||||
pricing_version: null,
|
||||
plugins: [{
|
||||
plugin_id: DATA_SERVICE_PLUGIN_DEFINITION.id,
|
||||
supported_contract_versions: [1],
|
||||
status: 'active' as const,
|
||||
capabilities: [{
|
||||
capability_id: 'data-service.documents',
|
||||
operations: [{
|
||||
operation: 'get_document',
|
||||
billing: { mode: 'included' as const, entitlement_scope: null, notice: 'Included quota' },
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}),
|
||||
};
|
||||
const service = createCodingProjectPluginService({
|
||||
projects: {
|
||||
getProject: vi.fn().mockResolvedValue({ id: 'local-a', path: root }),
|
||||
},
|
||||
projectPlugins,
|
||||
policyClient,
|
||||
adapters: [adapter],
|
||||
definitions: [DATA_SERVICE_PLUGIN_DEFINITION],
|
||||
});
|
||||
|
||||
const result = await service.list('local-a');
|
||||
|
||||
expect(policyClient.refresh).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual({
|
||||
schemaVersion: 1,
|
||||
project: {
|
||||
localProjectId: 'local-a',
|
||||
durableProjectId: '11111111-1111-4111-8111-111111111111',
|
||||
},
|
||||
policyStatus: 'current',
|
||||
items: [expect.objectContaining({
|
||||
id: DATA_SERVICE_PLUGIN_DEFINITION.id,
|
||||
enabled: true,
|
||||
state: 'degraded',
|
||||
backend: {
|
||||
status: 'degraded', code: 'plugin_backend_unavailable',
|
||||
message: 'Plugin backend is temporarily unavailable', retryable: true,
|
||||
},
|
||||
skills: [{ id: 'data-service', assignedAgentIds: ['builder'] }],
|
||||
capabilities: [{
|
||||
id: 'data-service.documents',
|
||||
operations: [{
|
||||
id: 'get_document',
|
||||
billing: { mode: 'included', availability: 'available', notice: 'Included quota' },
|
||||
}],
|
||||
}],
|
||||
settingsSurface: 'data-service',
|
||||
})],
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toMatch(/secret upstream body|projectPath|entitlement_scope/u);
|
||||
});
|
||||
|
||||
it('deactivates only the requested adapter and isolates cleanup failures', async () => {
|
||||
const firstDeactivate = vi.fn().mockRejectedValue(new Error('cleanup failed'));
|
||||
const secondDeactivate = vi.fn().mockResolvedValue(undefined);
|
||||
const service = createCodingProjectPluginService({
|
||||
projects: { getProject: vi.fn() },
|
||||
projectPlugins: { getEnabledPluginIds: vi.fn(), setEnabled: vi.fn() },
|
||||
policyClient: { refresh: vi.fn(), getState: vi.fn() },
|
||||
adapters: [
|
||||
{ pluginId: 'plugin.first', inspect: vi.fn(), invoke: vi.fn(), deactivate: firstDeactivate },
|
||||
{ pluginId: 'plugin.second', inspect: vi.fn(), invoke: vi.fn(), deactivate: secondDeactivate },
|
||||
],
|
||||
definitions: [],
|
||||
});
|
||||
|
||||
await expect(service.deactivate('C:\\project', 'plugin.first')).resolves.toBeUndefined();
|
||||
expect(firstDeactivate).toHaveBeenCalledWith('C:\\project');
|
||||
expect(secondDeactivate).not.toHaveBeenCalled();
|
||||
|
||||
await expect(service.deactivate('C:\\project')).resolves.toBeUndefined();
|
||||
expect(firstDeactivate).toHaveBeenCalledTimes(2);
|
||||
expect(secondDeactivate).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
88
tests/unit/coding-plugin-lifecycle.test.ts
Normal file
88
tests/unit/coding-plugin-lifecycle.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AgentBrowserModule } from '../../electron/agent-browser';
|
||||
import { createCodingComposition } from '../../electron/api/coding-composition';
|
||||
import { createProjectPluginService } from '../../electron/coding-plugins/project-service';
|
||||
import { readCodingProjectConfigV2 } from '../../electron/coding-projects/project-config';
|
||||
import {
|
||||
createCodingProjectStore,
|
||||
createLocalCodingProject,
|
||||
createMemoryCodingProjectStorage,
|
||||
} from '../../electron/coding-projects/project-store';
|
||||
import { DATA_SERVICE_PLUGIN_ID } from '../../shared/coding-plugins';
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('coding plugin lifecycle wiring', () => {
|
||||
it('marks managed inputs stale and invalidates/deactivates only after a real disable', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-lifecycle-'));
|
||||
roots.push(root);
|
||||
const stale = vi.fn();
|
||||
const invalidate = vi.fn();
|
||||
const deactivate = vi.fn().mockResolvedValue(undefined);
|
||||
const service = createProjectPluginService({
|
||||
now: () => '2026-08-27T00:00:00.000Z',
|
||||
onManagedInputsChanged: stale,
|
||||
onAdapterDeactivated: async ({ projectPath }) => {
|
||||
invalidate('project_deactivated');
|
||||
await deactivate(projectPath);
|
||||
},
|
||||
});
|
||||
|
||||
await service.enable(root, DATA_SERVICE_PLUGIN_ID);
|
||||
await service.disable(root, DATA_SERVICE_PLUGIN_ID);
|
||||
await service.disable(root, DATA_SERVICE_PLUGIN_ID);
|
||||
|
||||
expect(stale).toHaveBeenCalledTimes(2);
|
||||
expect(stale.mock.calls.map(([event]) => event.revision)).toEqual([1, 2]);
|
||||
expect(invalidate).toHaveBeenCalledOnce();
|
||||
expect(deactivate).toHaveBeenCalledOnce();
|
||||
expect(deactivate).toHaveBeenCalledWith(path.resolve(root));
|
||||
});
|
||||
|
||||
it('deactivates before identity write and before runtime shutdown', async () => {
|
||||
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-identity-'));
|
||||
const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-runtime-'));
|
||||
roots.push(projectPath, userDataDir);
|
||||
const storage = createMemoryCodingProjectStorage();
|
||||
const store = createCodingProjectStore(storage);
|
||||
const legacy = await createLocalCodingProject({ projectPath }, store);
|
||||
const composition = createCodingComposition({
|
||||
storage,
|
||||
projectStore: store,
|
||||
browser: { close: vi.fn().mockResolvedValue(undefined) } as unknown as AgentBrowserModule,
|
||||
paths: {
|
||||
executablePath: process.execPath,
|
||||
cliPath: path.join(projectPath, 'unused-cli.js'),
|
||||
userDataDir,
|
||||
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
||||
},
|
||||
});
|
||||
const secondId = '22222222-2222-4222-8222-222222222222';
|
||||
const order: string[] = [];
|
||||
vi.spyOn(composition.plugins, 'deactivate').mockImplementation(async () => {
|
||||
const snapshot = await readCodingProjectConfigV2(projectPath);
|
||||
order.push(`deactivate:${snapshot.status === 'valid' ? snapshot.config.projectId : 'invalid'}`);
|
||||
});
|
||||
vi.spyOn(composition.runtime, 'shutdown').mockImplementation(async () => {
|
||||
order.push('runtime-shutdown');
|
||||
});
|
||||
|
||||
await composition.projects.resolveProjectIdentity(legacy.project.id, {
|
||||
kind: 'bind', projectId: secondId,
|
||||
});
|
||||
await composition.shutdown();
|
||||
|
||||
expect(order[0]).toBe('deactivate:undefined');
|
||||
expect(order).toContain(`deactivate:${secondId}`);
|
||||
expect(order.at(-1)).toBe('runtime-shutdown');
|
||||
});
|
||||
});
|
||||
99
tests/unit/coding-plugin-routes.test.ts
Normal file
99
tests/unit/coding-plugin-routes.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { HostApiContext } from '../../electron/api/context';
|
||||
import { handleCodingPluginRoutes } from '../../electron/api/routes/coding-plugins';
|
||||
|
||||
function request(method: string, body?: unknown): IncomingMessage {
|
||||
const req = new EventEmitter();
|
||||
const raw = body === undefined ? undefined : JSON.stringify(body);
|
||||
Object.assign(req, {
|
||||
method,
|
||||
headers: raw === undefined ? {} : { 'content-length': String(Buffer.byteLength(raw)) },
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
if (raw !== undefined) yield Buffer.from(raw);
|
||||
},
|
||||
});
|
||||
return req as IncomingMessage;
|
||||
}
|
||||
|
||||
function response() {
|
||||
const chunks: string[] = [];
|
||||
const res = new EventEmitter();
|
||||
Object.assign(res, {
|
||||
statusCode: 0,
|
||||
setHeader: vi.fn(),
|
||||
end: vi.fn((chunk?: string) => { if (chunk) chunks.push(chunk); }),
|
||||
});
|
||||
return {
|
||||
res: res as unknown as ServerResponse,
|
||||
get status() { return (res as { statusCode: number }).statusCode; },
|
||||
json: () => JSON.parse(chunks.join('')) as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
async function invoke(ctx: HostApiContext, method: string, target: string, body?: unknown) {
|
||||
const output = response();
|
||||
const handled = await handleCodingPluginRoutes(
|
||||
request(method, body),
|
||||
output.res,
|
||||
new URL(`http://localhost${target}`),
|
||||
ctx,
|
||||
);
|
||||
return { handled, status: output.status, payload: output.json() };
|
||||
}
|
||||
|
||||
describe('coding plugin Host routes', () => {
|
||||
it('uses only the local project handle for GET and preserves the bounded projection', async () => {
|
||||
const projection = {
|
||||
schemaVersion: 1 as const,
|
||||
project: { localProjectId: 'local-a', durableProjectId: 'durable-a' },
|
||||
policyStatus: 'current' as const,
|
||||
items: [],
|
||||
};
|
||||
const list = vi.fn().mockResolvedValue(projection);
|
||||
const ctx = { codingProducts: { plugins: { list } } } as unknown as HostApiContext;
|
||||
|
||||
const result = await invoke(ctx, 'GET', '/api/coding/plugins?projectId=local-a');
|
||||
|
||||
expect(result).toMatchObject({ handled: true, status: 200, payload: projection });
|
||||
expect(list).toHaveBeenCalledWith('local-a');
|
||||
expect(JSON.stringify(result.payload)).not.toMatch(/projectPath|owner|token|entitlement_scope/u);
|
||||
});
|
||||
|
||||
it('accepts only the exact PUT body and never forwards authority fields', async () => {
|
||||
const setEnabled = vi.fn().mockResolvedValue({
|
||||
schemaVersion: 1,
|
||||
project: { localProjectId: 'local-a', durableProjectId: null },
|
||||
policyStatus: 'unavailable',
|
||||
items: [],
|
||||
});
|
||||
const ctx = { codingProducts: { plugins: { setEnabled } } } as unknown as HostApiContext;
|
||||
|
||||
const accepted = await invoke(ctx, 'PUT', '/api/coding/plugins/makelore.data-service', {
|
||||
projectId: 'local-a', enabled: true,
|
||||
});
|
||||
const rejected = await invoke(ctx, 'PUT', '/api/coding/plugins/makelore.data-service', {
|
||||
projectId: 'local-a', enabled: true, projectPath: 'C:\\untrusted',
|
||||
});
|
||||
|
||||
expect(accepted.status).toBe(200);
|
||||
expect(setEnabled).toHaveBeenCalledOnce();
|
||||
expect(setEnabled).toHaveBeenCalledWith('local-a', 'makelore.data-service', true);
|
||||
expect(rejected).toMatchObject({ status: 400, payload: { success: false, code: 'plugin_request_invalid' } });
|
||||
});
|
||||
|
||||
it('rejects duplicate or unexpected query authority before calling Main services', async () => {
|
||||
const list = vi.fn();
|
||||
const ctx = { codingProducts: { plugins: { list } } } as unknown as HostApiContext;
|
||||
|
||||
const duplicate = await invoke(ctx, 'GET', '/api/coding/plugins?projectId=local-a&projectId=local-b');
|
||||
const authority = await invoke(ctx, 'GET', '/api/coding/plugins?projectId=local-a&durableProjectId=forged');
|
||||
|
||||
expect(duplicate.status).toBe(400);
|
||||
expect(authority.status).toBe(400);
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -16,4 +16,20 @@ describe('Data Service Host API registration', () => {
|
||||
expect(worksIndex).toBeGreaterThan(dataServiceIndex);
|
||||
expect(source).not.toContain('handleDataServiceRoute = handleDataServiceRoutes');
|
||||
});
|
||||
|
||||
it('registers project plugins in the shared coding dispatcher without replacing typed configuration', async () => {
|
||||
const source = await readFile(resolve('electron/api/route-handlers.ts'), 'utf8');
|
||||
expect(source).toMatch(
|
||||
/import\s+\{\s*handleCodingPluginRoutes\s*\}\s+from\s+['"]\.\/routes\/coding-plugins['"]/,
|
||||
);
|
||||
const routeList = source.match(/hostApiRouteHandlers[^=]*=\s*\[([\s\S]*?)\];/);
|
||||
const routes = routeList?.[1] ?? '';
|
||||
expect(routes.indexOf('handleCodingPluginRoutes')).toBeGreaterThan(
|
||||
routes.indexOf('handleCodingProjectRoutes'),
|
||||
);
|
||||
expect(routes.indexOf('handleCodingConversationRoutes')).toBeGreaterThan(
|
||||
routes.indexOf('handleCodingPluginRoutes'),
|
||||
);
|
||||
expect(routes.indexOf('handleDataServiceRoutes')).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user