Files
makelore/tests/unit/project-plugin-service.test.ts

188 lines
7.4 KiB
TypeScript

// @vitest-environment node
import { readFile, stat } from 'node:fs/promises';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
createCodingProjectAgent,
createCodingProjectMetadata,
} from '../../electron/coding-projects/project-config';
import {
PROJECT_PLUGIN_SELECTION_PATH,
ProjectPluginService,
ProjectPluginServiceError,
} from '../../electron/coding-plugins/project-service';
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
async function project(): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-selection-'));
roots.push(root);
await createCodingProjectMetadata(root, { now: '2026-08-27T00:00:00.000Z' });
return root;
}
async function legacyProject(): Promise<string> {
const root = await project();
await createCodingProjectAgent(root, {
id: 'builder',
avatarId: 'avatar-01',
roleName: 'Builder',
name: 'Builder',
model: { accountId: 'account', modelId: 'model', thinkingLevel: 'medium' },
modelResolution: 'resolved',
skillIds: ['data-service'],
responsibility: {
mission: 'Build the project',
owns: [],
boundaries: [],
collaborators: [],
principles: [],
},
});
return root;
}
function selectionPath(root: string): string {
return path.join(root, PROJECT_PLUGIN_SELECTION_PATH);
}
describe('ProjectPluginService', () => {
it('does not create plugins.json during an ordinary missing read', async () => {
const root = await project();
const service = new ProjectPluginService();
await expect(service.readSelection(root)).resolves.toMatchObject({
status: 'missing',
source: 'none',
enabledPluginIds: [],
persisted: false,
});
await expect(stat(selectionPath(root))).rejects.toMatchObject({ code: 'ENOENT' });
await service.disable(root, 'makelore.data-service');
await expect(stat(selectionPath(root))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('projects the legacy Data Service Skill once without eager persistence', async () => {
const root = await legacyProject();
const service = new ProjectPluginService();
await expect(service.readSelection(root)).resolves.toMatchObject({
status: 'missing',
source: 'legacy',
enabledPluginIds: ['makelore.data-service'],
legacyProjectedPluginIds: ['makelore.data-service'],
persisted: false,
});
await expect(stat(selectionPath(root))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('does not turn a new Skill assignment into legacy plugin enablement', async () => {
const root = await project();
const service = new ProjectPluginService();
await expect(service.readSelection(root)).resolves.toMatchObject({
source: 'none', enabledPluginIds: [],
});
await createCodingProjectAgent(root, {
id: 'builder', avatarId: 'avatar-01', roleName: 'Builder', name: 'Builder',
model: null, modelResolution: 'required', skillIds: ['data-service'],
responsibility: { mission: 'Build', owns: [], boundaries: [], collaborators: [], principles: [] },
});
await expect(service.readSelection(root)).resolves.toMatchObject({
source: 'none', enabledPluginIds: [], legacyProjectedPluginIds: [],
});
});
it('writes deterministic, atomic selection and preserves unknown IDs', async () => {
const root = await project();
await writeFile(selectionPath(root), JSON.stringify({
schemaVersion: 1,
enabledPluginIds: ['z.future-plugin', 'makelore.data-service', 'z.future-plugin'],
updatedAt: '2026-08-27T01:00:00.000Z',
}), 'utf8');
const service = new ProjectPluginService({ now: () => '2026-08-27T02:00:00.000Z' });
await expect(service.readSelection(root)).resolves.toMatchObject({
source: 'file',
enabledPluginIds: ['makelore.data-service', 'z.future-plugin'],
unknownPluginIds: ['z.future-plugin'],
});
const next = await service.disable(root, 'makelore.data-service');
expect(next.enabledPluginIds).toEqual(['z.future-plugin']);
expect(JSON.parse(await readFile(selectionPath(root), 'utf8'))).toEqual({
schemaVersion: 1,
enabledPluginIds: ['z.future-plugin'],
updatedAt: '2026-08-27T02:00:00.000Z',
});
});
it('enables and disables idempotently while invoking lifecycle callbacks once', async () => {
const root = await project();
const managed: unknown[] = [];
const deactivated: unknown[] = [];
let writes = 0;
const service = new ProjectPluginService({
now: () => '2026-08-27T03:00:00.000Z',
writer: async (filePath, value) => {
writes += 1;
const { atomicWriteJson } = await import('../../electron/coding-projects/atomic-json');
await atomicWriteJson(filePath, value);
},
onManagedInputsChanged: (event) => { managed.push(event); },
onAdapterDeactivated: (event) => { deactivated.push(event); },
});
await service.enable(root, 'makelore.data-service');
await service.enable(root, 'makelore.data-service');
await service.disable(root, 'makelore.data-service');
await service.disable(root, 'makelore.data-service');
expect(writes).toBe(2);
expect(managed).toHaveLength(2);
expect(deactivated).toEqual([{ projectPath: root, pluginId: 'makelore.data-service' }]);
expect(service.getManagedInputRevision(root)).toBe(2);
});
it('keeps selection unchanged when atomic persistence fails', async () => {
const root = await project();
const service = new ProjectPluginService({
writer: async () => { throw new Error('disk full'); },
});
await expect(service.enable(root, 'makelore.data-service')).rejects.toMatchObject({
code: 'CODING_PLUGIN_SELECTION_WRITE_FAILED',
} satisfies Partial<ProjectPluginServiceError>);
await expect(stat(selectionPath(root))).rejects.toMatchObject({ code: 'ENOENT' });
await expect(service.readSelection(root)).resolves.toMatchObject({ enabledPluginIds: [] });
});
it('allows only explicit removal of a selected unknown ID and preserves every other ID', async () => {
const root = await project();
await writeFile(selectionPath(root), JSON.stringify({
schemaVersion: 1,
enabledPluginIds: ['future.plugin', 'another.future', 'makelore.data-service'],
updatedAt: '2026-08-27T04:00:00.000Z',
}), 'utf8');
const service = new ProjectPluginService();
await expect(service.enable(root, 'future.plugin')).rejects.toMatchObject({
code: 'CODING_PLUGIN_UNKNOWN',
});
await expect(service.disable(root, 'future.plugin')).resolves.toMatchObject({
enabledPluginIds: ['another.future', 'makelore.data-service'],
});
const saved = JSON.parse(await readFile(selectionPath(root), 'utf8')) as { enabledPluginIds: string[] };
expect(saved).toMatchObject({
enabledPluginIds: ['another.future', 'makelore.data-service'],
});
});
it('rejects unselected unknown disable without writing a selection file', async () => {
const root = await project();
const service = new ProjectPluginService();
await expect(service.disable(root, 'future.plugin')).rejects.toMatchObject({
code: 'CODING_PLUGIN_UNKNOWN',
});
await expect(stat(selectionPath(root))).rejects.toMatchObject({ code: 'ENOENT' });
});
});