314 lines
10 KiB
TypeScript
314 lines
10 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import * as fs from 'node:fs/promises';
|
|
import * as path from 'node:path';
|
|
|
|
import { PluginInstallationStateService } from '@main/services/extensions/state/PluginInstallationStateService';
|
|
|
|
const TEST_ROOT = path.parse(process.cwd()).root || path.sep;
|
|
const MOCK_CLAUDE_BASE_PATH = path.join(TEST_ROOT, 'tmp', 'mock-claude');
|
|
const PROJECT_A_PATH = path.join(TEST_ROOT, 'tmp', 'project-a');
|
|
const PROJECT_B_PATH = path.join(TEST_ROOT, 'tmp', 'project-b');
|
|
|
|
function normalizeMockPath(filePath: unknown): string {
|
|
return String(filePath).replaceAll('\\', '/');
|
|
}
|
|
|
|
vi.mock('@main/utils/pathDecoder', () => ({
|
|
getClaudeBasePath: () => {
|
|
const cwd = process.cwd();
|
|
const windowsRoot = cwd.match(/^[A-Za-z]:[\\/]/)?.[0] ?? null;
|
|
const root = windowsRoot ?? '/';
|
|
const sep = windowsRoot ? '\\' : '/';
|
|
return `${root}tmp${sep}mock-claude`;
|
|
},
|
|
}));
|
|
|
|
vi.mock('node:fs/promises');
|
|
|
|
describe('PluginInstallationStateService', () => {
|
|
let service: PluginInstallationStateService;
|
|
const mockedFs = vi.mocked(fs);
|
|
|
|
beforeEach(() => {
|
|
service = new PluginInstallationStateService();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('getInstalledPlugins', () => {
|
|
it('returns user-scoped plugins enabled in user settings', async () => {
|
|
mockedFs.readFile.mockImplementation(async (filePath) => {
|
|
const normalizedPath = normalizeMockPath(filePath);
|
|
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
|
return JSON.stringify({
|
|
version: 2,
|
|
plugins: {
|
|
'context7@claude-plugins-official': [
|
|
{
|
|
scope: 'user',
|
|
installPath:
|
|
'/Users/test/.claude/plugins/cache/claude-plugins-official/context7/1.0.0',
|
|
version: '1.0.0',
|
|
installedAt: '2026-03-01T11:14:21.926Z',
|
|
},
|
|
],
|
|
'typescript-lsp@claude-plugins-official': [
|
|
{
|
|
scope: 'project',
|
|
version: '1.0.0',
|
|
installedAt: '2026-03-03T10:00:00.000Z',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
}
|
|
|
|
if (normalizedPath === normalizeMockPath(path.join(MOCK_CLAUDE_BASE_PATH, 'settings.json'))) {
|
|
return JSON.stringify({
|
|
enabledPlugins: {
|
|
'context7@claude-plugins-official': true,
|
|
},
|
|
});
|
|
}
|
|
|
|
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
});
|
|
|
|
const entries = await service.getInstalledPlugins();
|
|
|
|
expect(entries).toHaveLength(1);
|
|
expect(entries[0]).toMatchObject({
|
|
pluginId: 'context7@claude-plugins-official',
|
|
scope: 'user',
|
|
version: '1.0.0',
|
|
});
|
|
});
|
|
|
|
it('includes project and local scopes only for the active project', async () => {
|
|
mockedFs.readFile.mockImplementation(async (filePath) => {
|
|
const normalizedPath = normalizeMockPath(filePath);
|
|
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
|
return JSON.stringify({
|
|
version: 2,
|
|
plugins: {
|
|
'context7@claude-plugins-official': [
|
|
{
|
|
scope: 'user',
|
|
version: '1.0.0',
|
|
installedAt: '2026-03-01T11:14:21.926Z',
|
|
},
|
|
],
|
|
'typescript-lsp@claude-plugins-official': [
|
|
{
|
|
scope: 'project',
|
|
version: '1.1.0',
|
|
installedAt: '2026-03-03T10:00:00.000Z',
|
|
},
|
|
],
|
|
'formatter@claude-plugins-official': [
|
|
{
|
|
scope: 'local',
|
|
version: '2.0.0',
|
|
installedAt: '2026-03-04T10:00:00.000Z',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
}
|
|
|
|
if (normalizedPath === normalizeMockPath(path.join(MOCK_CLAUDE_BASE_PATH, 'settings.json'))) {
|
|
return JSON.stringify({
|
|
enabledPlugins: {
|
|
'context7@claude-plugins-official': true,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (normalizedPath === normalizeMockPath(path.join(PROJECT_A_PATH, '.claude', 'settings.json'))) {
|
|
return JSON.stringify({
|
|
enabledPlugins: {
|
|
'typescript-lsp@claude-plugins-official': true,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (
|
|
normalizedPath ===
|
|
normalizeMockPath(path.join(PROJECT_A_PATH, '.claude', 'settings.local.json'))
|
|
) {
|
|
return JSON.stringify({
|
|
enabledPlugins: {
|
|
'formatter@claude-plugins-official': true,
|
|
},
|
|
});
|
|
}
|
|
|
|
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
});
|
|
|
|
const entries = await service.getInstalledPlugins(PROJECT_A_PATH);
|
|
|
|
expect(entries.map((entry) => [entry.pluginId, entry.scope])).toEqual([
|
|
['context7@claude-plugins-official', 'user'],
|
|
['typescript-lsp@claude-plugins-official', 'project'],
|
|
['formatter@claude-plugins-official', 'local'],
|
|
]);
|
|
});
|
|
|
|
it('does not leak another project scope into the current project', async () => {
|
|
mockedFs.readFile.mockImplementation(async (filePath) => {
|
|
const normalizedPath = normalizeMockPath(filePath);
|
|
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
|
return JSON.stringify({
|
|
version: 2,
|
|
plugins: {
|
|
'typescript-lsp@claude-plugins-official': [
|
|
{
|
|
scope: 'project',
|
|
version: '1.1.0',
|
|
installedAt: '2026-03-03T10:00:00.000Z',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
}
|
|
|
|
if (normalizedPath.endsWith('/settings.json')) {
|
|
return JSON.stringify({ enabledPlugins: {} });
|
|
}
|
|
|
|
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
});
|
|
|
|
const entries = await service.getInstalledPlugins(PROJECT_B_PATH);
|
|
|
|
expect(entries).toEqual([]);
|
|
});
|
|
|
|
it('returns empty array when file does not exist', async () => {
|
|
const enoent = new Error('ENOENT') as NodeJS.ErrnoException;
|
|
enoent.code = 'ENOENT';
|
|
mockedFs.readFile.mockRejectedValue(enoent);
|
|
|
|
const entries = await service.getInstalledPlugins();
|
|
expect(entries).toEqual([]);
|
|
});
|
|
|
|
it('returns empty array for unexpected version', async () => {
|
|
mockedFs.readFile.mockImplementation(async (filePath) => {
|
|
const normalizedPath = normalizeMockPath(filePath);
|
|
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
|
return JSON.stringify({ version: 1, plugins: {} });
|
|
}
|
|
if (normalizedPath.endsWith('/settings.json')) {
|
|
return JSON.stringify({ enabledPlugins: {} });
|
|
}
|
|
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
});
|
|
|
|
const entries = await service.getInstalledPlugins();
|
|
expect(entries).toEqual([]);
|
|
});
|
|
|
|
it('caches within TTL', async () => {
|
|
mockedFs.readFile.mockImplementation(async (filePath) => {
|
|
const normalizedPath = normalizeMockPath(filePath);
|
|
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
|
return JSON.stringify({ version: 2, plugins: {} });
|
|
}
|
|
if (normalizedPath.endsWith('/settings.json')) {
|
|
return JSON.stringify({ enabledPlugins: {} });
|
|
}
|
|
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
});
|
|
|
|
await service.getInstalledPlugins();
|
|
await service.getInstalledPlugins();
|
|
|
|
expect(mockedFs.readFile).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('caches results independently per project path', async () => {
|
|
mockedFs.readFile.mockImplementation(async (filePath) => {
|
|
const normalizedPath = normalizeMockPath(filePath);
|
|
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
|
return JSON.stringify({ version: 2, plugins: {} });
|
|
}
|
|
if (normalizedPath.endsWith('/settings.json')) {
|
|
return JSON.stringify({ enabledPlugins: {} });
|
|
}
|
|
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
});
|
|
|
|
await service.getInstalledPlugins(PROJECT_A_PATH);
|
|
await service.getInstalledPlugins(PROJECT_B_PATH);
|
|
|
|
expect(mockedFs.readFile).toHaveBeenCalledTimes(8);
|
|
});
|
|
});
|
|
|
|
describe('getInstallCounts', () => {
|
|
it('parses install-counts-cache.json', async () => {
|
|
const countsData = {
|
|
version: 1,
|
|
fetchedAt: '2026-03-06T18:17:44.050Z',
|
|
counts: [
|
|
{ plugin: 'frontend-design@claude-plugins-official', unique_installs: 277472 },
|
|
{ plugin: 'context7@claude-plugins-official', unique_installs: 150681 },
|
|
],
|
|
};
|
|
|
|
mockedFs.readFile.mockResolvedValue(JSON.stringify(countsData));
|
|
|
|
const counts = await service.getInstallCounts();
|
|
|
|
expect(counts.get('frontend-design@claude-plugins-official')).toBe(277472);
|
|
expect(counts.get('context7@claude-plugins-official')).toBe(150681);
|
|
expect(counts.get('nonexistent')).toBeUndefined();
|
|
});
|
|
|
|
it('returns empty map when file does not exist', async () => {
|
|
const enoent = new Error('ENOENT') as NodeJS.ErrnoException;
|
|
enoent.code = 'ENOENT';
|
|
mockedFs.readFile.mockRejectedValue(enoent);
|
|
|
|
const counts = await service.getInstallCounts();
|
|
expect(counts.size).toBe(0);
|
|
});
|
|
|
|
it('caches within TTL', async () => {
|
|
mockedFs.readFile.mockResolvedValue(
|
|
JSON.stringify({ version: 1, counts: [] }),
|
|
);
|
|
|
|
await service.getInstallCounts();
|
|
await service.getInstallCounts();
|
|
|
|
expect(mockedFs.readFile).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('invalidateCache', () => {
|
|
it('forces re-read after invalidation', async () => {
|
|
mockedFs.readFile.mockImplementation(async (filePath) => {
|
|
const normalizedPath = String(filePath);
|
|
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
|
return JSON.stringify({ version: 2, plugins: {} });
|
|
}
|
|
if (normalizedPath.endsWith('/settings.json')) {
|
|
return JSON.stringify({ enabledPlugins: {} });
|
|
}
|
|
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
});
|
|
|
|
await service.getInstalledPlugins();
|
|
service.invalidateCache();
|
|
await service.getInstalledPlugins();
|
|
|
|
expect(mockedFs.readFile).toHaveBeenCalledTimes(4);
|
|
});
|
|
});
|
|
});
|