365 lines
16 KiB
TypeScript
365 lines
16 KiB
TypeScript
// @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 {
|
|
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 { createMemoryCodingProjectStorage } from '../../electron/coding-projects/project-store';
|
|
import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins';
|
|
|
|
const webSearchAdapterMock = vi.hoisted(() => ({
|
|
create: vi.fn(),
|
|
deactivate: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock('../../electron/coding-plugins/adapters/web-search', () => ({
|
|
createWebSearchPluginAdapter: webSearchAdapterMock.create.mockImplementation(() => ({
|
|
pluginId: 'makelore.web-search',
|
|
inspect: vi.fn().mockResolvedValue({ status: 'ready' }),
|
|
invoke: vi.fn(),
|
|
deactivate: webSearchAdapterMock.deactivate,
|
|
})),
|
|
}));
|
|
|
|
const roots: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
webSearchAdapterMock.create.mockClear();
|
|
webSearchAdapterMock.deactivate.mockClear();
|
|
});
|
|
|
|
describe('coding plugin bounded product service', () => {
|
|
it('registers the code-owned Web Search adapter in the production composition', async () => {
|
|
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-web-search-composition-project-'));
|
|
const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-web-search-composition-user-'));
|
|
roots.push(projectPath, userDataDir);
|
|
const composition = createCodingComposition({
|
|
storage: createMemoryCodingProjectStorage(),
|
|
browser: { close: vi.fn().mockResolvedValue(undefined) } as unknown as AgentBrowserModule,
|
|
paths: {
|
|
executablePath: process.execPath,
|
|
cliPath: path.join(projectPath, 'unused-cli.js'),
|
|
serverPath: path.join(projectPath, 'unused-server.mjs'),
|
|
userDataDir,
|
|
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
|
},
|
|
});
|
|
|
|
await composition.plugins.deactivate(projectPath, 'makelore.web-search');
|
|
|
|
expect(webSearchAdapterMock.create).toHaveBeenCalledOnce();
|
|
expect(webSearchAdapterMock.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
client: expect.any(Object),
|
|
marketplace: expect.any(Object),
|
|
packageStore: expect.any(Object),
|
|
makeloreVersion: '2.0.0',
|
|
}));
|
|
expect(webSearchAdapterMock.deactivate).toHaveBeenCalledWith(projectPath);
|
|
await composition.shutdown();
|
|
});
|
|
|
|
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',
|
|
unknownPluginIds: [],
|
|
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' },
|
|
tool: {
|
|
name: 'data_service_get_document',
|
|
label: 'Data Service get document',
|
|
description: 'Read a document from the active project.',
|
|
mutation: 'read',
|
|
permissions: ['project.data.read'],
|
|
},
|
|
}],
|
|
}],
|
|
settingsSurface: 'data-service',
|
|
})],
|
|
});
|
|
expect(JSON.stringify(result)).not.toMatch(/secret upstream body|projectPath|entitlement_scope/u);
|
|
});
|
|
|
|
it('projects retained selected IDs without inventing package metadata', async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-unknown-'));
|
|
roots.push(root);
|
|
await createCodingProjectMetadata(root, { now: '2026-08-28T00:00:00.000Z' });
|
|
const service = createCodingProjectPluginService({
|
|
projects: { getProject: vi.fn().mockResolvedValue({ id: 'local-a', path: root }) },
|
|
projectPlugins: {
|
|
getEnabledPluginIds: vi.fn().mockResolvedValue([
|
|
'makelore.removed', DATA_SERVICE_PLUGIN_DEFINITION.id, 'makelore.removed',
|
|
]),
|
|
setEnabled: vi.fn(),
|
|
},
|
|
policyClient: {
|
|
refresh: vi.fn().mockResolvedValue(undefined),
|
|
getState: () => ({ status: 'unavailable' as const, revision: 0, lastVerifiedAt: null, catalog: null }),
|
|
},
|
|
adapters: [],
|
|
definitions: [DATA_SERVICE_PLUGIN_DEFINITION],
|
|
});
|
|
|
|
const result = await service.list('local-a');
|
|
expect(result.unknownPluginIds).toEqual(['makelore.removed']);
|
|
expect(result.items).toHaveLength(1);
|
|
expect(result.items).not.toContainEqual(expect.objectContaining({ id: 'makelore.removed' }));
|
|
});
|
|
|
|
it('projects a client-incompatible installed package as unavailable', async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-incompatible-'));
|
|
roots.push(root);
|
|
await createCodingProjectMetadata(root, { now: '2026-08-28T00:00:00.000Z' });
|
|
await createCodingProjectAgent(root, {
|
|
id: 'builder', avatarId: 'avatar-01', roleName: '实现者', name: 'Builder',
|
|
model: null, modelResolution: 'required', skillIds: ['notes'],
|
|
responsibility: { mission: 'Build', owns: [], boundaries: [], collaborators: [], principles: [] },
|
|
});
|
|
const definition = {
|
|
...DATA_SERVICE_PLUGIN_DEFINITION,
|
|
id: 'makelore.notes', displayName: 'Notes', description: 'Notes',
|
|
requiresBackend: false, runtimeKind: 'skill_only' as const,
|
|
acquisitionMode: 'user_acquired' as const, releaseId: 'release-notes',
|
|
provenance: { source: 'marketplace' as const, packageRoot: 'C:/packages/release-notes' },
|
|
adapterId: '', operations: [], tools: [],
|
|
skills: [{ id: 'notes', entryPath: 'skills/notes/SKILL.md', grants: [] }],
|
|
surfaces: {},
|
|
};
|
|
const service = createCodingProjectPluginService({
|
|
projects: { getProject: vi.fn().mockResolvedValue({ id: 'local-a', path: root }) },
|
|
projectPlugins: {
|
|
getEnabledPluginIds: vi.fn().mockResolvedValue([definition.id]), setEnabled: vi.fn(),
|
|
},
|
|
policyClient: {
|
|
refresh: vi.fn(),
|
|
getState: () => ({ status: 'unavailable' as const, revision: 0, lastVerifiedAt: null, catalog: null }),
|
|
},
|
|
adapters: [], definitions: [definition],
|
|
effectiveResolver: {
|
|
resolve: vi.fn().mockResolvedValue({
|
|
accountSessionId: 'account-a\u00001', projectId: 'local-a',
|
|
pluginReleaseIds: [], effectiveSkillIds: [], skillEntries: [],
|
|
toolDefinitions: [], runtimePolicies: [],
|
|
unavailableReasons: [{
|
|
pluginId: definition.id, code: 'client_incompatible',
|
|
message: 'Plugin Release is incompatible with this MakeLore client',
|
|
}],
|
|
}),
|
|
} as never,
|
|
});
|
|
|
|
await expect(service.list('local-a')).resolves.toMatchObject({
|
|
items: [{ id: definition.id, enabled: true, state: 'unavailable' }],
|
|
});
|
|
});
|
|
|
|
it('projects a Marketplace Skill owner collision as unavailable', async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-collision-'));
|
|
roots.push(root);
|
|
await createCodingProjectMetadata(root, { now: '2026-08-29T00:00:00.000Z' });
|
|
const definition = {
|
|
...DATA_SERVICE_PLUGIN_DEFINITION,
|
|
id: 'makelore.data-service-shadow', displayName: 'Data Service Shadow',
|
|
description: 'Conflicting package', requiresBackend: false, runtimeKind: 'skill_only' as const,
|
|
acquisitionMode: 'user_acquired' as const, releaseId: 'release-shadow',
|
|
provenance: { source: 'marketplace' as const, packageRoot: 'C:/packages/release-shadow' },
|
|
adapterId: '', operations: [], tools: [],
|
|
skills: [{ id: 'data-service', entryPath: 'skills/data-service/SKILL.md', grants: [] }],
|
|
surfaces: {},
|
|
};
|
|
const service = createCodingProjectPluginService({
|
|
projects: { getProject: vi.fn().mockResolvedValue({ id: 'local-a', path: root }) },
|
|
projectPlugins: {
|
|
getEnabledPluginIds: vi.fn().mockResolvedValue([definition.id]), setEnabled: vi.fn(),
|
|
},
|
|
policyClient: {
|
|
refresh: vi.fn(),
|
|
getState: () => ({ status: 'unavailable' as const, revision: 0, lastVerifiedAt: null, catalog: null }),
|
|
},
|
|
adapters: [], definitions: [definition],
|
|
effectiveResolver: {
|
|
resolve: vi.fn().mockResolvedValue({
|
|
accountSessionId: 'account-a\u00001', projectId: 'local-a',
|
|
pluginReleaseIds: [], effectiveSkillIds: [], skillEntries: [],
|
|
toolDefinitions: [], runtimePolicies: [],
|
|
unavailableReasons: [{
|
|
pluginId: definition.id, code: 'skill_owner_conflict',
|
|
message: 'Plugin Skill ID conflicts with an existing owner',
|
|
}],
|
|
}),
|
|
} as never,
|
|
});
|
|
|
|
await expect(service.list('local-a')).resolves.toMatchObject({
|
|
items: [{ id: definition.id, enabled: true, state: 'unavailable' }],
|
|
});
|
|
});
|
|
|
|
it('projects the exact three capabilities and fourteen mixed-policy operations', async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-policy-join-'));
|
|
roots.push(root);
|
|
await createCodingProjectMetadata(root, { now: '2026-08-27T00:00:00.000Z' });
|
|
const projectPlugins = createProjectPluginService();
|
|
await projectPlugins.enable(root, DATA_SERVICE_PLUGIN_DEFINITION.id);
|
|
const capabilities = ['data-service.control', 'data-service.documents', 'data-service.preview'].map((capabilityId) => ({
|
|
capability_id: capabilityId,
|
|
operations: DATA_SERVICE_PLUGIN_DEFINITION.operations
|
|
.filter((operation) => operation.capabilityId === capabilityId)
|
|
.map(({ operation }, index) => ({
|
|
operation,
|
|
billing: capabilityId === 'data-service.documents'
|
|
? { mode: 'external_account' as const, notice: 'Provider billed' }
|
|
: index === 0 && capabilityId === 'data-service.control'
|
|
? {
|
|
mode: 'platform_metered' as const,
|
|
status: 'billing_unavailable' as const,
|
|
entitlement_scope: capabilityId,
|
|
notice: 'Pricing unavailable',
|
|
}
|
|
: { 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: {
|
|
refresh: vi.fn(),
|
|
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,
|
|
}],
|
|
},
|
|
}),
|
|
},
|
|
adapters: [{
|
|
pluginId: DATA_SERVICE_PLUGIN_DEFINITION.id,
|
|
inspect: vi.fn().mockResolvedValue({ status: 'ready' }), invoke: vi.fn(),
|
|
}],
|
|
definitions: [DATA_SERVICE_PLUGIN_DEFINITION],
|
|
});
|
|
|
|
const item = (await service.list('local-a')).items[0];
|
|
expect(item?.capabilities.map(({ id }) => id)).toEqual([
|
|
'data-service.control', 'data-service.documents', 'data-service.preview',
|
|
]);
|
|
expect(item?.capabilities.flatMap(({ operations }) => operations)).toHaveLength(14);
|
|
expect(item?.capabilities.find(({ id }) => id === 'data-service.preview')?.operations)
|
|
.toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ id: 'get', tool: null, billing: expect.objectContaining({ mode: 'included' }) }),
|
|
expect.objectContaining({ id: 'delete', tool: null }),
|
|
]));
|
|
expect(item?.capabilities.find(({ id }) => id === 'data-service.documents')?.operations[0]?.billing)
|
|
.toMatchObject({ mode: 'external_account', availability: 'available' });
|
|
expect(item?.capabilities.find(({ id }) => id === 'data-service.control')?.operations[0]?.billing)
|
|
.toMatchObject({ mode: 'platform_metered', availability: 'unavailable' });
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|