// @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 { 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 { 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); await expect(stat(selectionPath(root))).rejects.toMatchObject({ code: 'ENOENT' }); await expect(service.readSelection(root)).resolves.toMatchObject({ enabledPluginIds: [] }); }); it('rejects unknown plugin mutations but retains unknown IDs already in the file', async () => { const root = await project(); await writeFile(selectionPath(root), JSON.stringify({ schemaVersion: 1, enabledPluginIds: ['future.plugin'], 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.enable(root, 'makelore.data-service')).resolves.toMatchObject({ enabledPluginIds: ['future.plugin', 'makelore.data-service'], }); }); });