diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index c466fb1d..a67943a0 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -125,13 +125,7 @@ export function hasInstallationInScope( return installations.some((installation) => installation.scope === scope); } -/** - * Build a concise install-status label for plugin badges. - */ -export function getInstallationSummaryLabel( - installations: Pick[] -): string | null { - const scopes = Array.from(new Set(installations.map((installation) => installation.scope))); +function summarizeInstallationScopes(scopes: InstallScope[]): string | null { if (scopes.length === 0) { return null; } @@ -152,6 +146,16 @@ export function getInstallationSummaryLabel( } } +/** + * Build a concise install-status label for plugin badges. + */ +export function getInstallationSummaryLabel( + installations: Pick[] +): string | null { + const scopes = Array.from(new Set(installations.map((installation) => installation.scope))); + return summarizeInstallationScopes(scopes); +} + const MCP_SCOPE_PRIORITY: Record = { local: 0, project: 1, @@ -181,24 +185,7 @@ export function getMcpInstallationSummaryLabel( installations: Pick[] ): string | null { const scopes = Array.from(new Set(installations.map((installation) => installation.scope))); - if (scopes.length === 0) { - return null; - } - - if (scopes.length > 1) { - return `Installed in ${scopes.length} scopes`; - } - - switch (scopes[0]) { - case 'user': - return 'Installed globally'; - case 'project': - return 'Installed in project'; - case 'local': - return 'Installed locally'; - default: - return 'Installed'; - } + return summarizeInstallationScopes(scopes); } /** diff --git a/test/main/services/extensions/McpInstallationStateService.test.ts b/test/main/services/extensions/McpInstallationStateService.test.ts index 61bfb566..32d23ef6 100644 --- a/test/main/services/extensions/McpInstallationStateService.test.ts +++ b/test/main/services/extensions/McpInstallationStateService.test.ts @@ -1,10 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; import { McpInstallationStateService } from '@main/services/extensions/state/McpInstallationStateService'; +const TEST_ROOT = path.parse(process.cwd()).root || path.sep; +const MOCK_HOME_PATH = path.join(TEST_ROOT, 'tmp', 'mock-home'); +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', () => ({ - getHomeDir: () => '/tmp/mock-home', + getHomeDir: () => MOCK_HOME_PATH, })); vi.mock('node:fs/promises'); @@ -25,14 +35,14 @@ describe('McpInstallationStateService', () => { describe('getInstalled', () => { it('includes local scope from the current project entry in ~/.claude.json', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { - const normalizedPath = String(filePath); - if (normalizedPath === '/tmp/mock-home/.claude.json') { + const normalizedPath = normalizeMockPath(filePath); + if (normalizedPath === normalizeMockPath(path.join(MOCK_HOME_PATH, '.claude.json'))) { return JSON.stringify({ mcpServers: { context7: { command: 'npx -y @upstash/context7-mcp' }, }, projects: { - '/tmp/project-a': { + [PROJECT_A_PATH]: { mcpServers: { stripe: { url: 'https://mcp.stripe.com' }, }, @@ -41,7 +51,7 @@ describe('McpInstallationStateService', () => { }); } - if (normalizedPath === '/tmp/project-a/.mcp.json') { + if (normalizedPath === normalizeMockPath(path.join(PROJECT_A_PATH, '.mcp.json'))) { return JSON.stringify({ mcpServers: { paypal: { url: 'https://mcp.paypal.com/mcp' }, @@ -52,7 +62,7 @@ describe('McpInstallationStateService', () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); - const entries = await service.getInstalled('/tmp/project-a'); + const entries = await service.getInstalled(PROJECT_A_PATH); expect(entries).toEqual([ { name: 'context7', scope: 'user', transport: 'stdio' }, @@ -63,8 +73,8 @@ describe('McpInstallationStateService', () => { it('caches results within TTL for the same project path', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { - const normalizedPath = String(filePath); - if (normalizedPath === '/tmp/mock-home/.claude.json') { + const normalizedPath = normalizeMockPath(filePath); + if (normalizedPath === normalizeMockPath(path.join(MOCK_HOME_PATH, '.claude.json'))) { return JSON.stringify({ mcpServers: { context7: { command: 'npx -y @upstash/context7-mcp' }, @@ -72,7 +82,7 @@ describe('McpInstallationStateService', () => { }); } - if (normalizedPath === '/tmp/project-a/.mcp.json') { + if (normalizedPath === normalizeMockPath(path.join(PROJECT_A_PATH, '.mcp.json'))) { return JSON.stringify({ mcpServers: { 'repo-a-server': { url: 'https://repo-a.example.com/mcp' }, @@ -83,27 +93,27 @@ describe('McpInstallationStateService', () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); - await service.getInstalled('/tmp/project-a'); - await service.getInstalled('/tmp/project-a'); + await service.getInstalled(PROJECT_A_PATH); + await service.getInstalled(PROJECT_A_PATH); expect(mockedFs.readFile).toHaveBeenCalledTimes(2); }); it('caches results independently per project path', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { - const normalizedPath = String(filePath); - if (normalizedPath === '/tmp/mock-home/.claude.json') { + const normalizedPath = normalizeMockPath(filePath); + if (normalizedPath === normalizeMockPath(path.join(MOCK_HOME_PATH, '.claude.json'))) { return JSON.stringify({ mcpServers: { context7: { command: 'npx -y @upstash/context7-mcp' }, }, projects: { - '/tmp/project-a': { + [PROJECT_A_PATH]: { mcpServers: { stripe: { url: 'https://mcp.stripe.com' }, }, }, - '/tmp/project-b': { + [PROJECT_B_PATH]: { mcpServers: { github: { command: 'uvx github-mcp' }, }, @@ -112,7 +122,7 @@ describe('McpInstallationStateService', () => { }); } - if (normalizedPath === '/tmp/project-a/.mcp.json') { + if (normalizedPath === normalizeMockPath(path.join(PROJECT_A_PATH, '.mcp.json'))) { return JSON.stringify({ mcpServers: { 'repo-a-server': { url: 'https://repo-a.example.com/mcp' }, @@ -120,7 +130,7 @@ describe('McpInstallationStateService', () => { }); } - if (normalizedPath === '/tmp/project-b/.mcp.json') { + if (normalizedPath === normalizeMockPath(path.join(PROJECT_B_PATH, '.mcp.json'))) { return JSON.stringify({ mcpServers: { 'repo-b-server': { command: 'uvx repo-b-mcp' }, @@ -131,8 +141,8 @@ describe('McpInstallationStateService', () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); - const projectAEntries = await service.getInstalled('/tmp/project-a'); - const projectBEntries = await service.getInstalled('/tmp/project-b'); + const projectAEntries = await service.getInstalled(PROJECT_A_PATH); + const projectBEntries = await service.getInstalled(PROJECT_B_PATH); expect(projectAEntries).toEqual([ { name: 'context7', scope: 'user', transport: 'stdio' }, diff --git a/test/main/services/extensions/PluginInstallationStateService.test.ts b/test/main/services/extensions/PluginInstallationStateService.test.ts index 5bec6049..f7c71536 100644 --- a/test/main/services/extensions/PluginInstallationStateService.test.ts +++ b/test/main/services/extensions/PluginInstallationStateService.test.ts @@ -9,7 +9,7 @@ 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: string | URL): string { +function normalizeMockPath(filePath: unknown): string { return String(filePath).replaceAll('\\', '/'); } diff --git a/test/renderer/components/extensions/plugins/PluginDetailDialog.test.ts b/test/renderer/components/extensions/plugins/PluginDetailDialog.test.ts index 753761f8..9a7157a0 100644 --- a/test/renderer/components/extensions/plugins/PluginDetailDialog.test.ts +++ b/test/renderer/components/extensions/plugins/PluginDetailDialog.test.ts @@ -257,9 +257,15 @@ describe('PluginDetailDialog project context', () => { }); const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + const projectOption = scopeSelect.querySelector( + 'option[value="project"]' + ) as HTMLOptionElement | null; + const localOption = scopeSelect.querySelector( + 'option[value="local"]' + ) as HTMLOptionElement | null; expect(scopeSelect).not.toBeNull(); - expect(scopeSelect.querySelector('option[value="project"]')?.disabled).toBe(true); - expect(scopeSelect.querySelector('option[value="local"]')?.disabled).toBe(true); + expect(projectOption?.disabled).toBe(true); + expect(localOption?.disabled).toBe(true); await act(async () => { root.unmount();