From 8d234674e7b6c861b7aa8c10acb61e395b30abf5 Mon Sep 17 00:00:00 2001 From: filmaj Date: Fri, 26 Jun 2026 15:14:54 -0400 Subject: [PATCH 1/7] test(chore): start of converting mcp configure tests --- .../commands/mcp/__tests__/configure.test.ts | 802 +---------------- .../@sanity/cli/src/commands/mcp/configure.ts | 8 +- .../commands/mcp/configure.test.ts | 804 ++++++++++++++++++ 3 files changed, 831 insertions(+), 783 deletions(-) create mode 100644 packages/@sanity/cli/test/integration/commands/mcp/configure.test.ts diff --git a/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts b/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts index 0056bd2a2e..6298bbe9aa 100644 --- a/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts +++ b/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts @@ -1,804 +1,46 @@ -import {existsSync, type PathLike} from 'node:fs' -import fs from 'node:fs/promises' - -import {checkbox} from '@sanity/cli-core/ux' -import {convertToSystemPath, createTestToken, testCommand} from '@sanity/cli-test' -import {execa} from 'execa' -import {cleanAll, pendingMocks} from 'nock' -import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' - -import {ConfigureMcpCommand} from '../configure.js' - -const mockCreateMCPToken = vi.hoisted(() => vi.fn()) -const mockEnsureAuthenticated = vi.hoisted(() => vi.fn()) -const mockIsInteractive = vi.hoisted(() => vi.fn().mockReturnValue(true)) -const mockValidateMCPToken = vi.hoisted(() => vi.fn()) - -vi.mock('../../../actions/auth/ensureAuthenticated.js', async (importOriginal) => { - const actual = - await importOriginal() - return { - ...actual, - ensureAuthenticated: mockEnsureAuthenticated, - } -}) +import {afterEach, beforeEach, describe, test, vi} from 'vitest' +import {createMockSanityCommand} from '../../../../test/mockSanityCommand.js' +// First: create the mocks and mocked SanityCommand class +const {MockedSanityCommand, mocks} = createMockSanityCommand() +// Second: install the mock on cli-core vi.mock('@sanity/cli-core', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - isInteractive: mockIsInteractive, + SanityCommand: MockedSanityCommand, } }) -vi.mock('../../../services/mcp.js', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - createMCPToken: mockCreateMCPToken, - validateMCPToken: mockValidateMCPToken, - } -}) - -vi.mock('@sanity/cli-core/ux', async () => { - const actual = await vi.importActual('@sanity/cli-core/ux') - return { - ...actual, - checkbox: vi.fn(), - } -}) - -vi.mock('node:fs', () => ({ - existsSync: vi.fn(), -})) +// Third: mock mcp configure command imports +const mockEnsureAuthenticated = vi.hoisted(() => vi.fn()) +const mockSetupMCP = vi.hoisted(() => vi.fn()) -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../../actions/auth/ensureAuthenticated.js', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, - default: { - mkdir: vi.fn(), - readFile: vi.fn(), - writeFile: vi.fn(), - }, + ensureAuthenticated: mockEnsureAuthenticated, } }) -vi.mock('execa', () => ({ - execa: vi.fn(), +vi.mock('../../../actions/mcp/setupMCP.js', () => ({ + setupMCP: mockSetupMCP, })) -const mockCheckbox = vi.mocked(checkbox) -const mockExistsSync = vi.mocked(existsSync) -const mockReadFile = vi.mocked(fs.readFile) -const mockWriteFile = vi.mocked(fs.writeFile) -const mockExeca = vi.mocked(execa) - -function mockMCPTokenCreation(token: string): void { - mockCreateMCPToken.mockResolvedValueOnce(token) -} - -// --------------------------------------------------------------------------- -// Helpers for table-driven per-editor tests -// --------------------------------------------------------------------------- - -interface EditorTestCase { - /** How to make this editor detectable */ - detect: { - /** CLI commands that should succeed (e.g. ['codex', 'claude']) */ - cliCommands?: string[] - /** Env vars to set for this test */ - env?: Record - /** existsSync predicate — receives the path string */ - existsSync?: (p: string) => boolean - /** Platform to override via Object.defineProperty */ - overridePlatform?: NodeJS.Platform - } - /** Substring the written config path must contain */ - expectedConfigPath: string - /** Editor name as it appears in EDITOR_CONFIGS */ - name: string - - /** Extra substrings the written content must contain (beyond the token) */ - expectedContentChecks?: string[] - /** If true, this editor uses OAuth natively and does not need an embedded API token */ - oauthOnly?: boolean - /** Only run on this platform (skipped otherwise) */ - platform?: NodeJS.Platform -} - -const EXECA_SUCCESS = { - command: 'test --version', - exitCode: 0, - failed: false, - killed: false, - signal: undefined, - stderr: '', - stdout: '1.0.0', - timedOut: false, -} as never - -/** Shared helper: sets up mocks, runs command, asserts outputs for a single editor. */ -async function runEditorTest(tc: EditorTestCase): Promise { - const originalPlatform = process.platform - const envBackups: Record = {} - - try { - // Platform override - if (tc.detect.overridePlatform) { - Object.defineProperty(process, 'platform', {value: tc.detect.overridePlatform}) - } - - // Env var overrides - if (tc.detect.env) { - for (const [key, value] of Object.entries(tc.detect.env)) { - envBackups[key] = process.env[key] - process.env[key] = value - } - } - - // existsSync mock - if (tc.detect.existsSync) { - const predicate = tc.detect.existsSync - mockExistsSync.mockImplementation((p: PathLike) => predicate(String(p))) - } - - // CLI mock — resolve for specific commands, reject for everything else - if (tc.detect.cliCommands) { - const commands = tc.detect.cliCommands - mockExeca.mockImplementation((async (command: string | URL) => { - if (commands.includes(String(command))) return EXECA_SUCCESS - throw new Error('Not installed') - }) as never) - } - - mockCheckbox.mockResolvedValue([tc.name]) +// Finally, import the module under test: mcp configure command +const {ConfigureMcpCommand} = await import('../configure.js') - const token = `test-token-${tc.name.toLowerCase().replaceAll(/\s+/g, '-')}` - - if (!tc.oauthOnly) { - mockMCPTokenCreation(token) - } - - const {stdout} = await testCommand(ConfigureMcpCommand, []) - - // Assert config was written to the expected path with the token - expect(mockWriteFile).toHaveBeenCalledWith( - expect.stringContaining(convertToSystemPath(tc.expectedConfigPath)), - tc.oauthOnly ? expect.not.stringContaining(token) : expect.stringContaining(token), - 'utf8', - ) - - // Assert extra content checks - if (tc.expectedContentChecks) { - const writtenContent = mockWriteFile.mock.calls[0]?.[1] as string - for (const check of tc.expectedContentChecks) { - expect(writtenContent, `written content should contain "${check}"`).toContain(check) - } - } - - expect(stdout).toContain(`MCP configured for ${tc.name}`) - } finally { - // Restore platform - if (tc.detect.overridePlatform) { - Object.defineProperty(process, 'platform', {value: originalPlatform}) - } - // Restore env vars - for (const [key, original] of Object.entries(envBackups)) { - if (original === undefined) { - delete process.env[key] - } else { - process.env[key] = original - } - } - } -} - -// --------------------------------------------------------------------------- -// Test cases — one entry per editor/variant -// --------------------------------------------------------------------------- - -const editorTestCases: EditorTestCase[] = [ - { - detect: {existsSync: (p) => p.endsWith('.cursor')}, - expectedConfigPath: '.cursor/mcp.json', - name: 'Cursor', - oauthOnly: true, - }, - { - detect: { - existsSync: (p) => p.endsWith('Code/User'), - overridePlatform: 'darwin', - }, - expectedConfigPath: 'Code/User/mcp.json', - name: 'VS Code', - platform: 'darwin', - }, - { - detect: { - env: {APPDATA: String.raw`C:\Users\test\AppData\Roaming`}, - existsSync: (p) => p.includes(String.raw`AppData\Roaming\Code\User`), - overridePlatform: 'win32', - }, - expectedConfigPath: String.raw`AppData\Roaming\Code\User\mcp.json`, - name: 'VS Code', - platform: 'win32', - }, - { - detect: { - existsSync: (p) => p.endsWith('Code - Insiders/User'), - overridePlatform: 'darwin', - }, - expectedConfigPath: 'Code - Insiders/User/mcp.json', - name: 'VS Code Insiders', - platform: 'darwin', - }, - { - detect: { - env: {APPDATA: String.raw`C:\Users\test\AppData\Roaming`}, - existsSync: (p) => p.includes(String.raw`AppData\Roaming\Code - Insiders\User`), - overridePlatform: 'win32', - }, - expectedConfigPath: String.raw`AppData\Roaming\Code - Insiders\User\mcp.json`, - name: 'VS Code Insiders', - platform: 'win32', - }, - { - detect: {cliCommands: ['claude']}, - expectedConfigPath: '.claude.json', - name: 'Claude Code', - oauthOnly: true, - }, - { - detect: { - existsSync: (p) => { - const n = p.replaceAll('\\', '/') - return n.endsWith('/.gemini/antigravity') - }, - }, - expectedConfigPath: '.gemini/antigravity/mcp_config.json', - expectedContentChecks: ['serverUrl'], - name: 'Antigravity', - }, - { - detect: { - existsSync: (p) => { - const n = p.replaceAll('\\', '/') - return n.endsWith('/Code/User/globalStorage/saoudrizwan.claude-dev/settings') - }, - }, - expectedConfigPath: - 'Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json', - name: 'Cline', - }, - { - detect: { - env: { - CLINE_DIR: - process.platform === 'win32' - ? String.raw`C:\tmp\custom-cline-home` - : '/tmp/custom-cline-home', - }, - existsSync: (p) => { - const n = p.replaceAll('\\', '/') - return n.endsWith('/tmp/custom-cline-home') - }, - }, - expectedConfigPath: convertToSystemPath( - '/tmp/custom-cline-home/data/settings/cline_mcp_settings.json', - ), - name: 'Cline CLI', - }, - { - detect: { - existsSync: (p) => { - const n = p.replaceAll('\\', '/') - return n.endsWith('/.gemini/settings.json') - }, - }, - expectedConfigPath: '.gemini/settings.json', - name: 'Gemini CLI', - }, - { - detect: { - existsSync: (p) => { - const n = p.replaceAll('\\', '/') - return /\/.?copilot(?:\/|$)/.test(n) - }, - }, - expectedConfigPath: 'mcp-config.json', - expectedContentChecks: ['"tools"'], - name: 'GitHub Copilot CLI', - }, - { - detect: { - env: {XDG_CONFIG_HOME: '/home/user/.config'}, - existsSync: (p) => p.includes('/home/user/.config/copilot'), - }, - expectedConfigPath: '/home/user/.config/copilot/mcp-config.json', - name: 'GitHub Copilot CLI', - platform: 'linux', - }, - { - detect: {cliCommands: ['opencode'], overridePlatform: 'darwin'}, - expectedConfigPath: '.config/opencode/opencode.json', - name: 'OpenCode', - platform: 'darwin', - }, - { - detect: {cliCommands: ['codex']}, - expectedConfigPath: '.codex/config.toml', - expectedContentChecks: ['[mcp_servers.Sanity]', '[mcp_servers.Sanity.http_headers]'], - name: 'Codex CLI', - }, - { - detect: { - cliCommands: ['codex'], - env: { - CODEX_HOME: - process.platform === 'win32' - ? String.raw`C:\tmp\custom-codex-home` - : '/tmp/custom-codex-home', - }, - }, - expectedConfigPath: convertToSystemPath('/tmp/custom-codex-home/config.toml'), - name: 'Codex CLI', - }, - { - detect: { - existsSync: (p) => p.includes('.config/zed'), - overridePlatform: 'darwin', - }, - expectedConfigPath: '.config/zed/settings.json', - name: 'Zed', - platform: 'darwin', - }, - { - detect: { - env: {APPDATA: String.raw`C:\Users\test\AppData\Roaming`}, - existsSync: (p) => p.includes(String.raw`AppData\Roaming\Zed`), - overridePlatform: 'win32', - }, - expectedConfigPath: String.raw`AppData\Roaming\Zed\settings.json`, - name: 'Zed', - platform: 'win32', - }, -] - -// MCPorter has three variants for file format detection -const mcporterTestCases: Array<{ - existsSync: (p: string) => boolean - expectedConfigPath: string - label: string -}> = [ - { - existsSync: (p) => { - const n = p.replaceAll('\\', '/') - if (n.endsWith('/.mcporter')) return true - if (n.endsWith('/.mcporter/mcporter.json')) return false - if (n.endsWith('/.mcporter/mcporter.jsonc')) return true - return false - }, - expectedConfigPath: '.mcporter/mcporter.jsonc', - label: 'existing jsonc config', - }, - { - existsSync: (p) => { - const n = p.replaceAll('\\', '/') - if (n.endsWith('/.mcporter')) return true - if (n.endsWith('/.mcporter/mcporter.json')) return true - if (n.endsWith('/.mcporter/mcporter.jsonc')) return false - return false - }, - expectedConfigPath: '.mcporter/mcporter.json', - label: 'existing json config', - }, - { - existsSync: (p) => { - const n = p.replaceAll('\\', '/') - if (n.endsWith('/.mcporter')) return true - if (n.endsWith('/.mcporter/mcporter.json')) return false - if (n.endsWith('/.mcporter/mcporter.jsonc')) return false - return false - }, - expectedConfigPath: '.mcporter/mcporter.json', - label: 'fresh install (defaults to json)', - }, -] - -// --------------------------------------------------------------------------- -// Main test suite -// --------------------------------------------------------------------------- - -describe.sequential('#mcp:configure', () => { - beforeEach(async () => { - mockEnsureAuthenticated.mockResolvedValue({ - email: 'test@example.com', - id: 'user-123', - name: 'Test User', - provider: 'github', - }) - mockExistsSync.mockReturnValue(false) - mockReadFile.mockResolvedValue('{}') - mockWriteFile.mockResolvedValue() - mockExeca.mockRejectedValue(new Error('Not installed')) - mockCreateMCPToken.mockReset() - mockValidateMCPToken.mockReset() - createTestToken('eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEyMyJ9.signature') +describe('#mcp:configure', () => { + beforeEach(() => { + mocks.SanityCmdIsUnattended.mockReturnValue(false) + mockEnsureAuthenticated.mockResolvedValue({}) + mockSetupMCP.mockResolvedValue({configuredEditors:}) }) - afterEach(() => { vi.clearAllMocks() - const pending = pendingMocks() - cleanAll() - expect(pending, 'pending mocks').toEqual([]) - }) - - // ------------------------------------------------------------------------- - // Per-editor detection (table-driven) - // ------------------------------------------------------------------------- - - describe('editor detection and configuration', () => { - for (const tc of editorTestCases) { - const suffix = tc.platform ? ` on ${tc.platform}` : '' - const envNote = tc.detect.env ? ` (${Object.keys(tc.detect.env).join(', ')})` : '' - const label = `detects ${tc.name}${suffix}${envNote} and configures it` - - test.runIf(!tc.platform || process.platform === tc.platform)(label, () => runEditorTest(tc)) - } - - // MCPorter file-format variants - for (const mc of mcporterTestCases) { - test(`detects MCPorter with ${mc.label} and configures it`, () => - runEditorTest({ - detect: {existsSync: mc.existsSync}, - expectedConfigPath: mc.expectedConfigPath, - name: 'MCPorter', - })) - } - }) - - // ------------------------------------------------------------------------- - // Codex CLI: unparseable TOML skips editor - // ------------------------------------------------------------------------- - - test('skips Codex CLI when existing TOML config is unparseable', async () => { - mockExeca.mockImplementation((async (command: string | URL) => { - if (command === 'codex') return EXECA_SUCCESS - throw new Error('Not installed') - }) as never) - - mockExistsSync.mockImplementation((p: PathLike) => { - const normalized = String(p).replaceAll('\\', '/') - return normalized.endsWith('/config.toml') - }) - mockReadFile.mockResolvedValue('[[[') - mockCheckbox.mockResolvedValue([]) - - await testCommand(ConfigureMcpCommand, []) - - expect(mockCheckbox).not.toHaveBeenCalled() - expect(mockWriteFile).not.toHaveBeenCalled() - }) - - // ------------------------------------------------------------------------- - // Edge cases and no-editor scenario - // ------------------------------------------------------------------------- - - test('shows warning when no editors are detected', async () => { - mockExistsSync.mockReturnValue(false) - mockExeca.mockRejectedValue(new Error('Not installed')) - - const {error, stderr} = await testCommand(ConfigureMcpCommand, []) - - if (error) throw error - - expect(stderr).toContain("Couldn't auto-configure Sanity MCP server for your editor") - expect(stderr).toContain('https://mcp.sanity.io') - }) - - // ------------------------------------------------------------------------- - // Token lifecycle - // ------------------------------------------------------------------------- - - test('skips prompt when all configured editors have valid auth', async () => { - mockExistsSync.mockImplementation((path: PathLike) => { - return String(path).includes('.cursor') - }) - - mockReadFile.mockResolvedValue( - JSON.stringify({ - mcpServers: { - Sanity: { - headers: { - Authorization: 'Bearer existing-token', - }, - type: 'http', - url: 'https://mcp.sanity.io', - }, - }, - }), - ) - - mockValidateMCPToken.mockResolvedValueOnce(true) - - const {stdout} = await testCommand(ConfigureMcpCommand, []) - - // Should NOT prompt the user — everything is already configured - expect(mockCheckbox).not.toHaveBeenCalled() - expect(stdout).toContain('All detected editors are already configured') }) + test('ensures authentication and delegates to setupMCP on success', async () => { - test('skips prompt for oauthOnly editor configured without a bearer token', async () => { - mockExistsSync.mockImplementation((path: PathLike) => { - return String(path).includes('.cursor') - }) - - // Cursor config has Sanity entry but no Authorization header (pure OAuth) - mockReadFile.mockResolvedValue( - JSON.stringify({ - mcpServers: { - Sanity: { - type: 'http', - url: 'https://mcp.sanity.io', - }, - }, - }), - ) - - // No /users/me mock — oauthOnly editors with no token skip validation entirely - - const {stdout} = await testCommand(ConfigureMcpCommand, []) - - expect(mockCheckbox).not.toHaveBeenCalled() - expect(stdout).toContain('All detected editors are already configured') - }) - - test('shows auth expired annotation when configured token is invalid', async () => { - mockExistsSync.mockImplementation((path: PathLike) => { - return String(path).includes('.cursor') - }) - - mockReadFile.mockResolvedValue( - JSON.stringify({ - mcpServers: { - Sanity: { - headers: { - Authorization: 'Bearer expired-token', - }, - type: 'http', - url: 'https://mcp.sanity.io', - }, - }, - }), - ) - - mockValidateMCPToken.mockResolvedValueOnce(false) - - mockCheckbox.mockResolvedValue(['Cursor']) - - // Cursor is oauthOnly so no new token is created — config is rewritten without a token - - await testCommand(ConfigureMcpCommand, []) - - expect(mockCheckbox).toHaveBeenCalledWith({ - choices: [ - { - checked: true, - name: 'Cursor (auth expired)', - value: 'Cursor', - }, - ], - message: 'Configure Sanity MCP server?', - }) - }) - - test('reuses valid token from another editor instead of creating new one', async () => { - // Detect both Cursor (configured with valid token) and Gemini (unconfigured) - mockExistsSync.mockImplementation((path: PathLike) => { - const normalized = String(path).replaceAll('\\', '/') - return normalized.includes('/.cursor') || normalized.endsWith('/.gemini/settings.json') - }) - - // Cursor has existing config with valid token, Gemini has empty config - mockReadFile.mockImplementation(async (filePath: unknown) => { - if (String(filePath).includes('.cursor')) { - return JSON.stringify({ - mcpServers: { - Sanity: { - headers: {Authorization: 'Bearer valid-reusable-token'}, - type: 'http', - url: 'https://mcp.sanity.io', - }, - }, - }) - } - return '{}' - }) - - mockValidateMCPToken.mockResolvedValueOnce(true) - - // User selects only the unconfigured editor (Gemini CLI) - mockCheckbox.mockResolvedValue(['Gemini CLI']) - - // NO /auth/session/create or /auth/fetch mocks — token should be reused - - const {stdout} = await testCommand(ConfigureMcpCommand, []) - - // Should write config with the reused token - expect(mockWriteFile).toHaveBeenCalledWith( - expect.stringContaining(convertToSystemPath('.gemini/settings.json')), - expect.stringContaining('valid-reusable-token'), - 'utf8', - ) - - expect(stdout).toContain('MCP configured for Gemini CLI') - }) - - // ------------------------------------------------------------------------- - // User interaction - // ------------------------------------------------------------------------- - - test('exits gracefully when user deselects all editors', async () => { - mockExistsSync.mockImplementation((path: PathLike) => { - return String(path).includes('.cursor') - }) - - mockCheckbox.mockResolvedValue([]) - - const {stdout} = await testCommand(ConfigureMcpCommand, []) - - expect(stdout).toContain('MCP configuration skipped') - expect(mockWriteFile).not.toHaveBeenCalled() - }) - - test('configures multiple editors when selected', async () => { - mockExistsSync.mockReturnValue(true) - - mockCheckbox.mockResolvedValue(['Cursor', 'VS Code']) - - mockMCPTokenCreation('multi-token-123') - - const {stdout} = await testCommand(ConfigureMcpCommand, []) - - expect(mockWriteFile).toHaveBeenCalledTimes(2) - - expect(mockWriteFile).toHaveBeenCalledWith( - expect.stringContaining(convertToSystemPath('.cursor/mcp.json')), - expect.not.stringContaining('multi-token-123'), - 'utf8', - ) - expect(mockWriteFile).toHaveBeenCalledWith( - expect.stringContaining(convertToSystemPath('Code/User/mcp.json')), - expect.stringContaining('multi-token-123'), - 'utf8', - ) - - expect(stdout).toContain('MCP configured for Cursor, VS Code') - }) - - test('auto-selects all editors in non-interactive mode without prompting', async () => { - mockIsInteractive.mockReturnValue(false) - - mockExeca.mockImplementation((async (command: string | URL) => { - if (command === 'opencode') return EXECA_SUCCESS - throw new Error('Not installed') - }) as never) - - mockMCPTokenCreation('test-token-ci') - - const {stdout} = await testCommand(ConfigureMcpCommand, []) - - expect(mockCheckbox).not.toHaveBeenCalled() - expect(mockWriteFile).toHaveBeenCalledWith( - expect.stringContaining(convertToSystemPath('.config/opencode/opencode.json')), - expect.stringContaining('test-token-ci'), - 'utf8', - ) - expect(stdout).toContain('MCP configured for OpenCode') - }) - - // ------------------------------------------------------------------------- - // Error handling - // ------------------------------------------------------------------------- - - test('handles token creation error gracefully', async () => { - mockExeca.mockImplementation((async (command: string | URL) => { - if (command === 'opencode') return EXECA_SUCCESS - throw new Error('Not installed') - }) as never) - - mockCheckbox.mockResolvedValue(['OpenCode']) - - mockCreateMCPToken.mockRejectedValueOnce(new Error('Not authenticated')) - - const {stderr} = await testCommand(ConfigureMcpCommand, []) - - expect(stderr).toContain('Could not configure MCP') - expect(stderr).toContain('https://mcp.sanity.io') - expect(mockWriteFile).not.toHaveBeenCalled() - }) - - test('handles file write error gracefully', async () => { - mockExeca.mockImplementation((async (command: string | URL) => { - if (command === 'opencode') return EXECA_SUCCESS - throw new Error('Not installed') - }) as never) - - mockCheckbox.mockResolvedValue(['OpenCode']) - - mockMCPTokenCreation('token-write-error') - - mockWriteFile.mockRejectedValue(new Error('Permission denied')) - - const {stderr} = await testCommand(ConfigureMcpCommand, []) - - expect(stderr).toContain('Could not configure MCP') - expect(stderr).toContain('https://mcp.sanity.io') - }) - - test('suggests login when login fails', async () => { - const {LoginError} = await import('../../../errors/LoginError.js') - mockEnsureAuthenticated.mockRejectedValue(new LoginError('No authentication providers found')) - - const {error} = await testCommand(ConfigureMcpCommand, []) - - expect(error).toBeInstanceOf(Error) - expect(error?.message).toContain('No authentication providers found') - expect(error?.message).toContain('Try running `sanity login`') - expect(error?.oclif?.exit).toBe(1) - }) - - test('shows raw error for network failures without suggesting login', async () => { - mockEnsureAuthenticated.mockRejectedValue(new Error('request timed out')) - - const {error} = await testCommand(ConfigureMcpCommand, []) - - expect(error).toBeInstanceOf(Error) - expect(error?.message).toContain('request timed out') - expect(error?.message).not.toContain('sanity login') - expect(error?.oclif?.exit).toBe(1) - }) - - // ------------------------------------------------------------------------- - // Config merging - // ------------------------------------------------------------------------- - - test('merges with existing config file', async () => { - mockExeca.mockImplementation((async (command: string | URL) => { - if (command === 'opencode') return EXECA_SUCCESS - throw new Error('Not installed') - }) as never) - - mockExistsSync.mockImplementation((p: PathLike) => { - return String(p).endsWith('opencode.json') - }) - - mockReadFile.mockResolvedValue( - JSON.stringify({ - mcp: { - OtherServer: { - type: 'stdio', - }, - }, - }), - ) - - mockCheckbox.mockResolvedValue(['OpenCode']) - - mockMCPTokenCreation('merge-token-123') - - await testCommand(ConfigureMcpCommand, []) - - expect(mockWriteFile).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('OtherServer'), - 'utf8', - ) - expect(mockWriteFile).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('Sanity'), - 'utf8', - ) }) }) diff --git a/packages/@sanity/cli/src/commands/mcp/configure.ts b/packages/@sanity/cli/src/commands/mcp/configure.ts index 31c6dcb59c..20edb4fa5f 100644 --- a/packages/@sanity/cli/src/commands/mcp/configure.ts +++ b/packages/@sanity/cli/src/commands/mcp/configure.ts @@ -30,13 +30,15 @@ export class ConfigureMcpCommand extends SanityCommand vi.fn()) +const mockEnsureAuthenticated = vi.hoisted(() => vi.fn()) +const mockIsInteractive = vi.hoisted(() => vi.fn().mockReturnValue(true)) +const mockValidateMCPToken = vi.hoisted(() => vi.fn()) + +vi.mock('../../../../src/actions/auth/ensureAuthenticated.js', async (importOriginal) => { + const actual = + await importOriginal() + return { + ...actual, + ensureAuthenticated: mockEnsureAuthenticated, + } +}) + +vi.mock('@sanity/cli-core', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + isInteractive: mockIsInteractive, + } +}) + +vi.mock('../../../../src/services/mcp.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createMCPToken: mockCreateMCPToken, + validateMCPToken: mockValidateMCPToken, + } +}) + +vi.mock('@sanity/cli-core/ux', async () => { + const actual = await vi.importActual('@sanity/cli-core/ux') + return { + ...actual, + checkbox: vi.fn(), + } +}) + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + default: { + mkdir: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + }, + } +}) + +vi.mock('execa', () => ({ + execa: vi.fn(), +})) + +const mockCheckbox = vi.mocked(checkbox) +const mockExistsSync = vi.mocked(existsSync) +const mockReadFile = vi.mocked(fs.readFile) +const mockWriteFile = vi.mocked(fs.writeFile) +const mockExeca = vi.mocked(execa) + +function mockMCPTokenCreation(token: string): void { + mockCreateMCPToken.mockResolvedValueOnce(token) +} + +// --------------------------------------------------------------------------- +// Helpers for table-driven per-editor tests +// --------------------------------------------------------------------------- + +interface EditorTestCase { + /** How to make this editor detectable */ + detect: { + /** CLI commands that should succeed (e.g. ['codex', 'claude']) */ + cliCommands?: string[] + /** Env vars to set for this test */ + env?: Record + /** existsSync predicate — receives the path string */ + existsSync?: (p: string) => boolean + /** Platform to override via Object.defineProperty */ + overridePlatform?: NodeJS.Platform + } + /** Substring the written config path must contain */ + expectedConfigPath: string + /** Editor name as it appears in EDITOR_CONFIGS */ + name: string + + /** Extra substrings the written content must contain (beyond the token) */ + expectedContentChecks?: string[] + /** If true, this editor uses OAuth natively and does not need an embedded API token */ + oauthOnly?: boolean + /** Only run on this platform (skipped otherwise) */ + platform?: NodeJS.Platform +} + +const EXECA_SUCCESS = { + command: 'test --version', + exitCode: 0, + failed: false, + killed: false, + signal: undefined, + stderr: '', + stdout: '1.0.0', + timedOut: false, +} as never + +/** Shared helper: sets up mocks, runs command, asserts outputs for a single editor. */ +async function runEditorTest(tc: EditorTestCase): Promise { + const originalPlatform = process.platform + const envBackups: Record = {} + + try { + // Platform override + if (tc.detect.overridePlatform) { + Object.defineProperty(process, 'platform', {value: tc.detect.overridePlatform}) + } + + // Env var overrides + if (tc.detect.env) { + for (const [key, value] of Object.entries(tc.detect.env)) { + envBackups[key] = process.env[key] + process.env[key] = value + } + } + + // existsSync mock + if (tc.detect.existsSync) { + const predicate = tc.detect.existsSync + mockExistsSync.mockImplementation((p: PathLike) => predicate(String(p))) + } + + // CLI mock — resolve for specific commands, reject for everything else + if (tc.detect.cliCommands) { + const commands = tc.detect.cliCommands + mockExeca.mockImplementation((async (command: string | URL) => { + if (commands.includes(String(command))) return EXECA_SUCCESS + throw new Error('Not installed') + }) as never) + } + + mockCheckbox.mockResolvedValue([tc.name]) + + const token = `test-token-${tc.name.toLowerCase().replaceAll(/\s+/g, '-')}` + + if (!tc.oauthOnly) { + mockMCPTokenCreation(token) + } + + const {stdout} = await testCommand(ConfigureMcpCommand, []) + + // Assert config was written to the expected path with the token + expect(mockWriteFile).toHaveBeenCalledWith( + expect.stringContaining(convertToSystemPath(tc.expectedConfigPath)), + tc.oauthOnly ? expect.not.stringContaining(token) : expect.stringContaining(token), + 'utf8', + ) + + // Assert extra content checks + if (tc.expectedContentChecks) { + const writtenContent = mockWriteFile.mock.calls[0]?.[1] as string + for (const check of tc.expectedContentChecks) { + expect(writtenContent, `written content should contain "${check}"`).toContain(check) + } + } + + expect(stdout).toContain(`MCP configured for ${tc.name}`) + } finally { + // Restore platform + if (tc.detect.overridePlatform) { + Object.defineProperty(process, 'platform', {value: originalPlatform}) + } + // Restore env vars + for (const [key, original] of Object.entries(envBackups)) { + if (original === undefined) { + delete process.env[key] + } else { + process.env[key] = original + } + } + } +} + +// --------------------------------------------------------------------------- +// Test cases — one entry per editor/variant +// --------------------------------------------------------------------------- + +const editorTestCases: EditorTestCase[] = [ + { + detect: {existsSync: (p) => p.endsWith('.cursor')}, + expectedConfigPath: '.cursor/mcp.json', + name: 'Cursor', + oauthOnly: true, + }, + { + detect: { + existsSync: (p) => p.endsWith('Code/User'), + overridePlatform: 'darwin', + }, + expectedConfigPath: 'Code/User/mcp.json', + name: 'VS Code', + platform: 'darwin', + }, + { + detect: { + env: {APPDATA: String.raw`C:\Users\test\AppData\Roaming`}, + existsSync: (p) => p.includes(String.raw`AppData\Roaming\Code\User`), + overridePlatform: 'win32', + }, + expectedConfigPath: String.raw`AppData\Roaming\Code\User\mcp.json`, + name: 'VS Code', + platform: 'win32', + }, + { + detect: { + existsSync: (p) => p.endsWith('Code - Insiders/User'), + overridePlatform: 'darwin', + }, + expectedConfigPath: 'Code - Insiders/User/mcp.json', + name: 'VS Code Insiders', + platform: 'darwin', + }, + { + detect: { + env: {APPDATA: String.raw`C:\Users\test\AppData\Roaming`}, + existsSync: (p) => p.includes(String.raw`AppData\Roaming\Code - Insiders\User`), + overridePlatform: 'win32', + }, + expectedConfigPath: String.raw`AppData\Roaming\Code - Insiders\User\mcp.json`, + name: 'VS Code Insiders', + platform: 'win32', + }, + { + detect: {cliCommands: ['claude']}, + expectedConfigPath: '.claude.json', + name: 'Claude Code', + oauthOnly: true, + }, + { + detect: { + existsSync: (p) => { + const n = p.replaceAll('\\', '/') + return n.endsWith('/.gemini/antigravity') + }, + }, + expectedConfigPath: '.gemini/antigravity/mcp_config.json', + expectedContentChecks: ['serverUrl'], + name: 'Antigravity', + }, + { + detect: { + existsSync: (p) => { + const n = p.replaceAll('\\', '/') + return n.endsWith('/Code/User/globalStorage/saoudrizwan.claude-dev/settings') + }, + }, + expectedConfigPath: + 'Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json', + name: 'Cline', + }, + { + detect: { + env: { + CLINE_DIR: + process.platform === 'win32' + ? String.raw`C:\tmp\custom-cline-home` + : '/tmp/custom-cline-home', + }, + existsSync: (p) => { + const n = p.replaceAll('\\', '/') + return n.endsWith('/tmp/custom-cline-home') + }, + }, + expectedConfigPath: convertToSystemPath( + '/tmp/custom-cline-home/data/settings/cline_mcp_settings.json', + ), + name: 'Cline CLI', + }, + { + detect: { + existsSync: (p) => { + const n = p.replaceAll('\\', '/') + return n.endsWith('/.gemini/settings.json') + }, + }, + expectedConfigPath: '.gemini/settings.json', + name: 'Gemini CLI', + }, + { + detect: { + existsSync: (p) => { + const n = p.replaceAll('\\', '/') + return /\/.?copilot(?:\/|$)/.test(n) + }, + }, + expectedConfigPath: 'mcp-config.json', + expectedContentChecks: ['"tools"'], + name: 'GitHub Copilot CLI', + }, + { + detect: { + env: {XDG_CONFIG_HOME: '/home/user/.config'}, + existsSync: (p) => p.includes('/home/user/.config/copilot'), + }, + expectedConfigPath: '/home/user/.config/copilot/mcp-config.json', + name: 'GitHub Copilot CLI', + platform: 'linux', + }, + { + detect: {cliCommands: ['opencode'], overridePlatform: 'darwin'}, + expectedConfigPath: '.config/opencode/opencode.json', + name: 'OpenCode', + platform: 'darwin', + }, + { + detect: {cliCommands: ['codex']}, + expectedConfigPath: '.codex/config.toml', + expectedContentChecks: ['[mcp_servers.Sanity]', '[mcp_servers.Sanity.http_headers]'], + name: 'Codex CLI', + }, + { + detect: { + cliCommands: ['codex'], + env: { + CODEX_HOME: + process.platform === 'win32' + ? String.raw`C:\tmp\custom-codex-home` + : '/tmp/custom-codex-home', + }, + }, + expectedConfigPath: convertToSystemPath('/tmp/custom-codex-home/config.toml'), + name: 'Codex CLI', + }, + { + detect: { + existsSync: (p) => p.includes('.config/zed'), + overridePlatform: 'darwin', + }, + expectedConfigPath: '.config/zed/settings.json', + name: 'Zed', + platform: 'darwin', + }, + { + detect: { + env: {APPDATA: String.raw`C:\Users\test\AppData\Roaming`}, + existsSync: (p) => p.includes(String.raw`AppData\Roaming\Zed`), + overridePlatform: 'win32', + }, + expectedConfigPath: String.raw`AppData\Roaming\Zed\settings.json`, + name: 'Zed', + platform: 'win32', + }, +] + +// MCPorter has three variants for file format detection +const mcporterTestCases: Array<{ + existsSync: (p: string) => boolean + expectedConfigPath: string + label: string +}> = [ + { + existsSync: (p) => { + const n = p.replaceAll('\\', '/') + if (n.endsWith('/.mcporter')) return true + if (n.endsWith('/.mcporter/mcporter.json')) return false + if (n.endsWith('/.mcporter/mcporter.jsonc')) return true + return false + }, + expectedConfigPath: '.mcporter/mcporter.jsonc', + label: 'existing jsonc config', + }, + { + existsSync: (p) => { + const n = p.replaceAll('\\', '/') + if (n.endsWith('/.mcporter')) return true + if (n.endsWith('/.mcporter/mcporter.json')) return true + if (n.endsWith('/.mcporter/mcporter.jsonc')) return false + return false + }, + expectedConfigPath: '.mcporter/mcporter.json', + label: 'existing json config', + }, + { + existsSync: (p) => { + const n = p.replaceAll('\\', '/') + if (n.endsWith('/.mcporter')) return true + if (n.endsWith('/.mcporter/mcporter.json')) return false + if (n.endsWith('/.mcporter/mcporter.jsonc')) return false + return false + }, + expectedConfigPath: '.mcporter/mcporter.json', + label: 'fresh install (defaults to json)', + }, +] + +// --------------------------------------------------------------------------- +// Main test suite +// --------------------------------------------------------------------------- + +describe.sequential('#mcp:configure', () => { + beforeEach(async () => { + mockEnsureAuthenticated.mockResolvedValue({ + email: 'test@example.com', + id: 'user-123', + name: 'Test User', + provider: 'github', + }) + mockExistsSync.mockReturnValue(false) + mockReadFile.mockResolvedValue('{}') + mockWriteFile.mockResolvedValue() + mockExeca.mockRejectedValue(new Error('Not installed')) + mockCreateMCPToken.mockReset() + mockValidateMCPToken.mockReset() + createTestToken('eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEyMyJ9.signature') + }) + + afterEach(() => { + vi.clearAllMocks() + const pending = pendingMocks() + cleanAll() + expect(pending, 'pending mocks').toEqual([]) + }) + + // ------------------------------------------------------------------------- + // Per-editor detection (table-driven) + // ------------------------------------------------------------------------- + + describe('editor detection and configuration', () => { + for (const tc of editorTestCases) { + const suffix = tc.platform ? ` on ${tc.platform}` : '' + const envNote = tc.detect.env ? ` (${Object.keys(tc.detect.env).join(', ')})` : '' + const label = `detects ${tc.name}${suffix}${envNote} and configures it` + + test.runIf(!tc.platform || process.platform === tc.platform)(label, () => runEditorTest(tc)) + } + + // MCPorter file-format variants + for (const mc of mcporterTestCases) { + test(`detects MCPorter with ${mc.label} and configures it`, () => + runEditorTest({ + detect: {existsSync: mc.existsSync}, + expectedConfigPath: mc.expectedConfigPath, + name: 'MCPorter', + })) + } + }) + + // ------------------------------------------------------------------------- + // Codex CLI: unparseable TOML skips editor + // ------------------------------------------------------------------------- + + test('skips Codex CLI when existing TOML config is unparseable', async () => { + mockExeca.mockImplementation((async (command: string | URL) => { + if (command === 'codex') return EXECA_SUCCESS + throw new Error('Not installed') + }) as never) + + mockExistsSync.mockImplementation((p: PathLike) => { + const normalized = String(p).replaceAll('\\', '/') + return normalized.endsWith('/config.toml') + }) + mockReadFile.mockResolvedValue('[[[') + mockCheckbox.mockResolvedValue([]) + + await testCommand(ConfigureMcpCommand, []) + + expect(mockCheckbox).not.toHaveBeenCalled() + expect(mockWriteFile).not.toHaveBeenCalled() + }) + + // ------------------------------------------------------------------------- + // Edge cases and no-editor scenario + // ------------------------------------------------------------------------- + + test('shows warning when no editors are detected', async () => { + mockExistsSync.mockReturnValue(false) + mockExeca.mockRejectedValue(new Error('Not installed')) + + const {error, stderr} = await testCommand(ConfigureMcpCommand, []) + + if (error) throw error + + expect(stderr).toContain("Couldn't auto-configure Sanity MCP server for your editor") + expect(stderr).toContain('https://mcp.sanity.io') + }) + + // ------------------------------------------------------------------------- + // Token lifecycle + // ------------------------------------------------------------------------- + + test('skips prompt when all configured editors have valid auth', async () => { + mockExistsSync.mockImplementation((path: PathLike) => { + return String(path).includes('.cursor') + }) + + mockReadFile.mockResolvedValue( + JSON.stringify({ + mcpServers: { + Sanity: { + headers: { + Authorization: 'Bearer existing-token', + }, + type: 'http', + url: 'https://mcp.sanity.io', + }, + }, + }), + ) + + mockValidateMCPToken.mockResolvedValueOnce(true) + + const {stdout} = await testCommand(ConfigureMcpCommand, []) + + // Should NOT prompt the user — everything is already configured + expect(mockCheckbox).not.toHaveBeenCalled() + expect(stdout).toContain('All detected editors are already configured') + }) + + test('skips prompt for oauthOnly editor configured without a bearer token', async () => { + mockExistsSync.mockImplementation((path: PathLike) => { + return String(path).includes('.cursor') + }) + + // Cursor config has Sanity entry but no Authorization header (pure OAuth) + mockReadFile.mockResolvedValue( + JSON.stringify({ + mcpServers: { + Sanity: { + type: 'http', + url: 'https://mcp.sanity.io', + }, + }, + }), + ) + + // No /users/me mock — oauthOnly editors with no token skip validation entirely + + const {stdout} = await testCommand(ConfigureMcpCommand, []) + + expect(mockCheckbox).not.toHaveBeenCalled() + expect(stdout).toContain('All detected editors are already configured') + }) + + test('shows auth expired annotation when configured token is invalid', async () => { + mockExistsSync.mockImplementation((path: PathLike) => { + return String(path).includes('.cursor') + }) + + mockReadFile.mockResolvedValue( + JSON.stringify({ + mcpServers: { + Sanity: { + headers: { + Authorization: 'Bearer expired-token', + }, + type: 'http', + url: 'https://mcp.sanity.io', + }, + }, + }), + ) + + mockValidateMCPToken.mockResolvedValueOnce(false) + + mockCheckbox.mockResolvedValue(['Cursor']) + + // Cursor is oauthOnly so no new token is created — config is rewritten without a token + + await testCommand(ConfigureMcpCommand, []) + + expect(mockCheckbox).toHaveBeenCalledWith({ + choices: [ + { + checked: true, + name: 'Cursor (auth expired)', + value: 'Cursor', + }, + ], + message: 'Configure Sanity MCP server?', + }) + }) + + test('reuses valid token from another editor instead of creating new one', async () => { + // Detect both Cursor (configured with valid token) and Gemini (unconfigured) + mockExistsSync.mockImplementation((path: PathLike) => { + const normalized = String(path).replaceAll('\\', '/') + return normalized.includes('/.cursor') || normalized.endsWith('/.gemini/settings.json') + }) + + // Cursor has existing config with valid token, Gemini has empty config + mockReadFile.mockImplementation(async (filePath: unknown) => { + if (String(filePath).includes('.cursor')) { + return JSON.stringify({ + mcpServers: { + Sanity: { + headers: {Authorization: 'Bearer valid-reusable-token'}, + type: 'http', + url: 'https://mcp.sanity.io', + }, + }, + }) + } + return '{}' + }) + + mockValidateMCPToken.mockResolvedValueOnce(true) + + // User selects only the unconfigured editor (Gemini CLI) + mockCheckbox.mockResolvedValue(['Gemini CLI']) + + // NO /auth/session/create or /auth/fetch mocks — token should be reused + + const {stdout} = await testCommand(ConfigureMcpCommand, []) + + // Should write config with the reused token + expect(mockWriteFile).toHaveBeenCalledWith( + expect.stringContaining(convertToSystemPath('.gemini/settings.json')), + expect.stringContaining('valid-reusable-token'), + 'utf8', + ) + + expect(stdout).toContain('MCP configured for Gemini CLI') + }) + + // ------------------------------------------------------------------------- + // User interaction + // ------------------------------------------------------------------------- + + test('exits gracefully when user deselects all editors', async () => { + mockExistsSync.mockImplementation((path: PathLike) => { + return String(path).includes('.cursor') + }) + + mockCheckbox.mockResolvedValue([]) + + const {stdout} = await testCommand(ConfigureMcpCommand, []) + + expect(stdout).toContain('MCP configuration skipped') + expect(mockWriteFile).not.toHaveBeenCalled() + }) + + test('configures multiple editors when selected', async () => { + mockExistsSync.mockReturnValue(true) + + mockCheckbox.mockResolvedValue(['Cursor', 'VS Code']) + + mockMCPTokenCreation('multi-token-123') + + const {stdout} = await testCommand(ConfigureMcpCommand, []) + + expect(mockWriteFile).toHaveBeenCalledTimes(2) + + expect(mockWriteFile).toHaveBeenCalledWith( + expect.stringContaining(convertToSystemPath('.cursor/mcp.json')), + expect.not.stringContaining('multi-token-123'), + 'utf8', + ) + expect(mockWriteFile).toHaveBeenCalledWith( + expect.stringContaining(convertToSystemPath('Code/User/mcp.json')), + expect.stringContaining('multi-token-123'), + 'utf8', + ) + + expect(stdout).toContain('MCP configured for Cursor, VS Code') + }) + + test('auto-selects all editors in non-interactive mode without prompting', async () => { + mockIsInteractive.mockReturnValue(false) + + mockExeca.mockImplementation((async (command: string | URL) => { + if (command === 'opencode') return EXECA_SUCCESS + throw new Error('Not installed') + }) as never) + + mockMCPTokenCreation('test-token-ci') + + const {stdout} = await testCommand(ConfigureMcpCommand, []) + + expect(mockCheckbox).not.toHaveBeenCalled() + expect(mockWriteFile).toHaveBeenCalledWith( + expect.stringContaining(convertToSystemPath('.config/opencode/opencode.json')), + expect.stringContaining('test-token-ci'), + 'utf8', + ) + expect(stdout).toContain('MCP configured for OpenCode') + }) + + // ------------------------------------------------------------------------- + // Error handling + // ------------------------------------------------------------------------- + + test('handles token creation error gracefully', async () => { + mockExeca.mockImplementation((async (command: string | URL) => { + if (command === 'opencode') return EXECA_SUCCESS + throw new Error('Not installed') + }) as never) + + mockCheckbox.mockResolvedValue(['OpenCode']) + + mockCreateMCPToken.mockRejectedValueOnce(new Error('Not authenticated')) + + const {stderr} = await testCommand(ConfigureMcpCommand, []) + + expect(stderr).toContain('Could not configure MCP') + expect(stderr).toContain('https://mcp.sanity.io') + expect(mockWriteFile).not.toHaveBeenCalled() + }) + + test('handles file write error gracefully', async () => { + mockExeca.mockImplementation((async (command: string | URL) => { + if (command === 'opencode') return EXECA_SUCCESS + throw new Error('Not installed') + }) as never) + + mockCheckbox.mockResolvedValue(['OpenCode']) + + mockMCPTokenCreation('token-write-error') + + mockWriteFile.mockRejectedValue(new Error('Permission denied')) + + const {stderr} = await testCommand(ConfigureMcpCommand, []) + + expect(stderr).toContain('Could not configure MCP') + expect(stderr).toContain('https://mcp.sanity.io') + }) + + test('suggests login when login fails', async () => { + const {LoginError} = await import('../../../errors/LoginError.js') + mockEnsureAuthenticated.mockRejectedValue(new LoginError('No authentication providers found')) + + const {error} = await testCommand(ConfigureMcpCommand, []) + + expect(error).toBeInstanceOf(Error) + expect(error?.message).toContain('No authentication providers found') + expect(error?.message).toContain('Try running `sanity login`') + expect(error?.oclif?.exit).toBe(1) + }) + + test('shows raw error for network failures without suggesting login', async () => { + mockEnsureAuthenticated.mockRejectedValue(new Error('request timed out')) + + const {error} = await testCommand(ConfigureMcpCommand, []) + + expect(error).toBeInstanceOf(Error) + expect(error?.message).toContain('request timed out') + expect(error?.message).not.toContain('sanity login') + expect(error?.oclif?.exit).toBe(1) + }) + + // ------------------------------------------------------------------------- + // Config merging + // ------------------------------------------------------------------------- + + test('merges with existing config file', async () => { + mockExeca.mockImplementation((async (command: string | URL) => { + if (command === 'opencode') return EXECA_SUCCESS + throw new Error('Not installed') + }) as never) + + mockExistsSync.mockImplementation((p: PathLike) => { + return String(p).endsWith('opencode.json') + }) + + mockReadFile.mockResolvedValue( + JSON.stringify({ + mcp: { + OtherServer: { + type: 'stdio', + }, + }, + }), + ) + + mockCheckbox.mockResolvedValue(['OpenCode']) + + mockMCPTokenCreation('merge-token-123') + + await testCommand(ConfigureMcpCommand, []) + + expect(mockWriteFile).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('OtherServer'), + 'utf8', + ) + expect(mockWriteFile).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('Sanity'), + 'utf8', + ) + }) +}) From e411f8b3f2aa6c504c958943acdf6cf275fc41fb Mon Sep 17 00:00:00 2001 From: filmaj Date: Fri, 26 Jun 2026 15:15:35 -0400 Subject: [PATCH 2/7] test(chore): start of converting mcp configure tests --- .../@sanity/cli/src/commands/mcp/__tests__/configure.test.ts | 4 ++-- .../cli/test/integration/commands/mcp/configure.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts b/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts index 6298bbe9aa..52f95089ca 100644 --- a/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts +++ b/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts @@ -35,12 +35,12 @@ describe('#mcp:configure', () => { beforeEach(() => { mocks.SanityCmdIsUnattended.mockReturnValue(false) mockEnsureAuthenticated.mockResolvedValue({}) - mockSetupMCP.mockResolvedValue({configuredEditors:}) + mockSetupMCP.mockResolvedValue({configuredEditors: {}, detectedEditors: {}}) }) afterEach(() => { vi.clearAllMocks() }) test('ensures authentication and delegates to setupMCP on success', async () => { - + ConfigureMcpCommand.run() }) }) diff --git a/packages/@sanity/cli/test/integration/commands/mcp/configure.test.ts b/packages/@sanity/cli/test/integration/commands/mcp/configure.test.ts index bcf50da49c..8cd1cfb8d6 100644 --- a/packages/@sanity/cli/test/integration/commands/mcp/configure.test.ts +++ b/packages/@sanity/cli/test/integration/commands/mcp/configure.test.ts @@ -738,7 +738,7 @@ describe.sequential('#mcp:configure', () => { }) test('suggests login when login fails', async () => { - const {LoginError} = await import('../../../errors/LoginError.js') + const {LoginError} = await import('../../../../src/errors/LoginError.js') mockEnsureAuthenticated.mockRejectedValue(new LoginError('No authentication providers found')) const {error} = await testCommand(ConfigureMcpCommand, []) From 03f13aa22c4a68e9d5bc29aa36c52f4847c8ab17 Mon Sep 17 00:00:00 2001 From: filmaj Date: Fri, 26 Jun 2026 16:22:09 -0400 Subject: [PATCH 3/7] test(chore): add telemetry to mock sanity command, mcp configure test conversion --- .../commands/mcp/__tests__/configure.test.ts | 49 ++++++++++++++++++- .../@sanity/cli/src/commands/mcp/configure.ts | 4 +- .../@sanity/cli/test/mockSanityCommand.ts | 9 ++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts b/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts index 52f95089ca..2e25dffd08 100644 --- a/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts +++ b/packages/@sanity/cli/src/commands/mcp/__tests__/configure.test.ts @@ -1,6 +1,7 @@ -import {afterEach, beforeEach, describe, test, vi} from 'vitest' +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {createMockSanityCommand} from '../../../../test/mockSanityCommand.js' +import {LoginError} from '../../../errors/LoginError.js' // First: create the mocks and mocked SanityCommand class const {MockedSanityCommand, mocks} = createMockSanityCommand() // Second: install the mock on cli-core @@ -40,7 +41,51 @@ describe('#mcp:configure', () => { afterEach(() => { vi.clearAllMocks() }) + test('ensures authentication and delegates to setupMCP on success', async () => { - ConfigureMcpCommand.run() + mocks.SanityCmdIsUnattended.mockReturnValue(true) + await ConfigureMcpCommand.run() + expect(mockEnsureAuthenticated).toHaveBeenCalled() + expect(mockSetupMCP).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'auto', + }), + ) + }) + + test('calls setupMCP with prompt mode if is an interactive session', async () => { + mocks.SanityCmdIsUnattended.mockReturnValue(false) + await ConfigureMcpCommand.run() + expect(mockSetupMCP).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'prompt', + }), + ) + }) + + test('errors out with auth cred message if ensureAuthenticated throws LoginError', async () => { + mockEnsureAuthenticated.mockRejectedValue(new LoginError('boom')) + await ConfigureMcpCommand.run() + expect(mocks.SanityCmdOutputError).toHaveBeenCalledWith( + expect.stringContaining('credentials: boom'), + {exit: 1}, + ) + }) + + test('errors out with generic auth message if ensureAuthenticated throws', async () => { + mockEnsureAuthenticated.mockRejectedValue(new Error('boom')) + await ConfigureMcpCommand.run() + expect(mocks.SanityCmdOutputError).toHaveBeenCalledWith( + expect.stringContaining('check authentication: boom'), + {exit: 1}, + ) + }) + + test('errors out with generic auth message if setupMCP throws', async () => { + mockSetupMCP.mockRejectedValue(new Error('boom')) + await ConfigureMcpCommand.run() + expect(mocks.SanityCmdOutputError).toHaveBeenCalledWith(expect.stringContaining('boom'), { + exit: 1, + }) }) }) diff --git a/packages/@sanity/cli/src/commands/mcp/configure.ts b/packages/@sanity/cli/src/commands/mcp/configure.ts index 20edb4fa5f..41b40c2b06 100644 --- a/packages/@sanity/cli/src/commands/mcp/configure.ts +++ b/packages/@sanity/cli/src/commands/mcp/configure.ts @@ -1,4 +1,4 @@ -import {isInteractive, SanityCommand, subdebug} from '@sanity/cli-core' +import {SanityCommand, subdebug} from '@sanity/cli-core' import {ensureAuthenticated} from '../../actions/auth/ensureAuthenticated.js' import {setupMCP} from '../../actions/mcp/setupMCP.js' @@ -44,7 +44,7 @@ export class ConfigureMcpCommand extends SanityCommand mocks.SanityCmdTelemetry), + updateUserProperties: vi.fn(), + }, } return { @@ -29,6 +37,7 @@ export function createMockSanityCommand() { log: mocks.SanityCmdOutputLog, warn: mocks.SanityCmdOutputWarn, } + telemetry = mocks.SanityCmdTelemetry public exit(code?: number) { return mocks.OclifCmdExit(code) } From c377e823b46017496b5d9457194727271d3e6786 Mon Sep 17 00:00:00 2001 From: filmaj Date: Fri, 26 Jun 2026 16:36:24 -0400 Subject: [PATCH 4/7] chore(test): removing duplicate tests that already exist as unit tests --- .../commands/mcp/configure.test.ts | 337 ------------------ 1 file changed, 337 deletions(-) diff --git a/packages/@sanity/cli/test/integration/commands/mcp/configure.test.ts b/packages/@sanity/cli/test/integration/commands/mcp/configure.test.ts index 8cd1cfb8d6..3fe352fd96 100644 --- a/packages/@sanity/cli/test/integration/commands/mcp/configure.test.ts +++ b/packages/@sanity/cli/test/integration/commands/mcp/configure.test.ts @@ -82,31 +82,6 @@ function mockMCPTokenCreation(token: string): void { // Helpers for table-driven per-editor tests // --------------------------------------------------------------------------- -interface EditorTestCase { - /** How to make this editor detectable */ - detect: { - /** CLI commands that should succeed (e.g. ['codex', 'claude']) */ - cliCommands?: string[] - /** Env vars to set for this test */ - env?: Record - /** existsSync predicate — receives the path string */ - existsSync?: (p: string) => boolean - /** Platform to override via Object.defineProperty */ - overridePlatform?: NodeJS.Platform - } - /** Substring the written config path must contain */ - expectedConfigPath: string - /** Editor name as it appears in EDITOR_CONFIGS */ - name: string - - /** Extra substrings the written content must contain (beyond the token) */ - expectedContentChecks?: string[] - /** If true, this editor uses OAuth natively and does not need an embedded API token */ - oauthOnly?: boolean - /** Only run on this platform (skipped otherwise) */ - platform?: NodeJS.Platform -} - const EXECA_SUCCESS = { command: 'test --version', exitCode: 0, @@ -118,294 +93,6 @@ const EXECA_SUCCESS = { timedOut: false, } as never -/** Shared helper: sets up mocks, runs command, asserts outputs for a single editor. */ -async function runEditorTest(tc: EditorTestCase): Promise { - const originalPlatform = process.platform - const envBackups: Record = {} - - try { - // Platform override - if (tc.detect.overridePlatform) { - Object.defineProperty(process, 'platform', {value: tc.detect.overridePlatform}) - } - - // Env var overrides - if (tc.detect.env) { - for (const [key, value] of Object.entries(tc.detect.env)) { - envBackups[key] = process.env[key] - process.env[key] = value - } - } - - // existsSync mock - if (tc.detect.existsSync) { - const predicate = tc.detect.existsSync - mockExistsSync.mockImplementation((p: PathLike) => predicate(String(p))) - } - - // CLI mock — resolve for specific commands, reject for everything else - if (tc.detect.cliCommands) { - const commands = tc.detect.cliCommands - mockExeca.mockImplementation((async (command: string | URL) => { - if (commands.includes(String(command))) return EXECA_SUCCESS - throw new Error('Not installed') - }) as never) - } - - mockCheckbox.mockResolvedValue([tc.name]) - - const token = `test-token-${tc.name.toLowerCase().replaceAll(/\s+/g, '-')}` - - if (!tc.oauthOnly) { - mockMCPTokenCreation(token) - } - - const {stdout} = await testCommand(ConfigureMcpCommand, []) - - // Assert config was written to the expected path with the token - expect(mockWriteFile).toHaveBeenCalledWith( - expect.stringContaining(convertToSystemPath(tc.expectedConfigPath)), - tc.oauthOnly ? expect.not.stringContaining(token) : expect.stringContaining(token), - 'utf8', - ) - - // Assert extra content checks - if (tc.expectedContentChecks) { - const writtenContent = mockWriteFile.mock.calls[0]?.[1] as string - for (const check of tc.expectedContentChecks) { - expect(writtenContent, `written content should contain "${check}"`).toContain(check) - } - } - - expect(stdout).toContain(`MCP configured for ${tc.name}`) - } finally { - // Restore platform - if (tc.detect.overridePlatform) { - Object.defineProperty(process, 'platform', {value: originalPlatform}) - } - // Restore env vars - for (const [key, original] of Object.entries(envBackups)) { - if (original === undefined) { - delete process.env[key] - } else { - process.env[key] = original - } - } - } -} - -// --------------------------------------------------------------------------- -// Test cases — one entry per editor/variant -// --------------------------------------------------------------------------- - -const editorTestCases: EditorTestCase[] = [ - { - detect: {existsSync: (p) => p.endsWith('.cursor')}, - expectedConfigPath: '.cursor/mcp.json', - name: 'Cursor', - oauthOnly: true, - }, - { - detect: { - existsSync: (p) => p.endsWith('Code/User'), - overridePlatform: 'darwin', - }, - expectedConfigPath: 'Code/User/mcp.json', - name: 'VS Code', - platform: 'darwin', - }, - { - detect: { - env: {APPDATA: String.raw`C:\Users\test\AppData\Roaming`}, - existsSync: (p) => p.includes(String.raw`AppData\Roaming\Code\User`), - overridePlatform: 'win32', - }, - expectedConfigPath: String.raw`AppData\Roaming\Code\User\mcp.json`, - name: 'VS Code', - platform: 'win32', - }, - { - detect: { - existsSync: (p) => p.endsWith('Code - Insiders/User'), - overridePlatform: 'darwin', - }, - expectedConfigPath: 'Code - Insiders/User/mcp.json', - name: 'VS Code Insiders', - platform: 'darwin', - }, - { - detect: { - env: {APPDATA: String.raw`C:\Users\test\AppData\Roaming`}, - existsSync: (p) => p.includes(String.raw`AppData\Roaming\Code - Insiders\User`), - overridePlatform: 'win32', - }, - expectedConfigPath: String.raw`AppData\Roaming\Code - Insiders\User\mcp.json`, - name: 'VS Code Insiders', - platform: 'win32', - }, - { - detect: {cliCommands: ['claude']}, - expectedConfigPath: '.claude.json', - name: 'Claude Code', - oauthOnly: true, - }, - { - detect: { - existsSync: (p) => { - const n = p.replaceAll('\\', '/') - return n.endsWith('/.gemini/antigravity') - }, - }, - expectedConfigPath: '.gemini/antigravity/mcp_config.json', - expectedContentChecks: ['serverUrl'], - name: 'Antigravity', - }, - { - detect: { - existsSync: (p) => { - const n = p.replaceAll('\\', '/') - return n.endsWith('/Code/User/globalStorage/saoudrizwan.claude-dev/settings') - }, - }, - expectedConfigPath: - 'Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json', - name: 'Cline', - }, - { - detect: { - env: { - CLINE_DIR: - process.platform === 'win32' - ? String.raw`C:\tmp\custom-cline-home` - : '/tmp/custom-cline-home', - }, - existsSync: (p) => { - const n = p.replaceAll('\\', '/') - return n.endsWith('/tmp/custom-cline-home') - }, - }, - expectedConfigPath: convertToSystemPath( - '/tmp/custom-cline-home/data/settings/cline_mcp_settings.json', - ), - name: 'Cline CLI', - }, - { - detect: { - existsSync: (p) => { - const n = p.replaceAll('\\', '/') - return n.endsWith('/.gemini/settings.json') - }, - }, - expectedConfigPath: '.gemini/settings.json', - name: 'Gemini CLI', - }, - { - detect: { - existsSync: (p) => { - const n = p.replaceAll('\\', '/') - return /\/.?copilot(?:\/|$)/.test(n) - }, - }, - expectedConfigPath: 'mcp-config.json', - expectedContentChecks: ['"tools"'], - name: 'GitHub Copilot CLI', - }, - { - detect: { - env: {XDG_CONFIG_HOME: '/home/user/.config'}, - existsSync: (p) => p.includes('/home/user/.config/copilot'), - }, - expectedConfigPath: '/home/user/.config/copilot/mcp-config.json', - name: 'GitHub Copilot CLI', - platform: 'linux', - }, - { - detect: {cliCommands: ['opencode'], overridePlatform: 'darwin'}, - expectedConfigPath: '.config/opencode/opencode.json', - name: 'OpenCode', - platform: 'darwin', - }, - { - detect: {cliCommands: ['codex']}, - expectedConfigPath: '.codex/config.toml', - expectedContentChecks: ['[mcp_servers.Sanity]', '[mcp_servers.Sanity.http_headers]'], - name: 'Codex CLI', - }, - { - detect: { - cliCommands: ['codex'], - env: { - CODEX_HOME: - process.platform === 'win32' - ? String.raw`C:\tmp\custom-codex-home` - : '/tmp/custom-codex-home', - }, - }, - expectedConfigPath: convertToSystemPath('/tmp/custom-codex-home/config.toml'), - name: 'Codex CLI', - }, - { - detect: { - existsSync: (p) => p.includes('.config/zed'), - overridePlatform: 'darwin', - }, - expectedConfigPath: '.config/zed/settings.json', - name: 'Zed', - platform: 'darwin', - }, - { - detect: { - env: {APPDATA: String.raw`C:\Users\test\AppData\Roaming`}, - existsSync: (p) => p.includes(String.raw`AppData\Roaming\Zed`), - overridePlatform: 'win32', - }, - expectedConfigPath: String.raw`AppData\Roaming\Zed\settings.json`, - name: 'Zed', - platform: 'win32', - }, -] - -// MCPorter has three variants for file format detection -const mcporterTestCases: Array<{ - existsSync: (p: string) => boolean - expectedConfigPath: string - label: string -}> = [ - { - existsSync: (p) => { - const n = p.replaceAll('\\', '/') - if (n.endsWith('/.mcporter')) return true - if (n.endsWith('/.mcporter/mcporter.json')) return false - if (n.endsWith('/.mcporter/mcporter.jsonc')) return true - return false - }, - expectedConfigPath: '.mcporter/mcporter.jsonc', - label: 'existing jsonc config', - }, - { - existsSync: (p) => { - const n = p.replaceAll('\\', '/') - if (n.endsWith('/.mcporter')) return true - if (n.endsWith('/.mcporter/mcporter.json')) return true - if (n.endsWith('/.mcporter/mcporter.jsonc')) return false - return false - }, - expectedConfigPath: '.mcporter/mcporter.json', - label: 'existing json config', - }, - { - existsSync: (p) => { - const n = p.replaceAll('\\', '/') - if (n.endsWith('/.mcporter')) return true - if (n.endsWith('/.mcporter/mcporter.json')) return false - if (n.endsWith('/.mcporter/mcporter.jsonc')) return false - return false - }, - expectedConfigPath: '.mcporter/mcporter.json', - label: 'fresh install (defaults to json)', - }, -] - // --------------------------------------------------------------------------- // Main test suite // --------------------------------------------------------------------------- @@ -434,30 +121,6 @@ describe.sequential('#mcp:configure', () => { expect(pending, 'pending mocks').toEqual([]) }) - // ------------------------------------------------------------------------- - // Per-editor detection (table-driven) - // ------------------------------------------------------------------------- - - describe('editor detection and configuration', () => { - for (const tc of editorTestCases) { - const suffix = tc.platform ? ` on ${tc.platform}` : '' - const envNote = tc.detect.env ? ` (${Object.keys(tc.detect.env).join(', ')})` : '' - const label = `detects ${tc.name}${suffix}${envNote} and configures it` - - test.runIf(!tc.platform || process.platform === tc.platform)(label, () => runEditorTest(tc)) - } - - // MCPorter file-format variants - for (const mc of mcporterTestCases) { - test(`detects MCPorter with ${mc.label} and configures it`, () => - runEditorTest({ - detect: {existsSync: mc.existsSync}, - expectedConfigPath: mc.expectedConfigPath, - name: 'MCPorter', - })) - } - }) - // ------------------------------------------------------------------------- // Codex CLI: unparseable TOML skips editor // ------------------------------------------------------------------------- From ff460948324c7208a2da81f1fa2b22fcab7ddf21 Mon Sep 17 00:00:00 2001 From: filmaj Date: Fri, 26 Jun 2026 17:58:49 -0400 Subject: [PATCH 5/7] test(chore): unit tests for detectAvailableEditors.ts --- .../__tests__/detectAvailableEditors.test.ts | 191 ++++++++++++++++++ .../src/actions/mcp/detectAvailableEditors.ts | 4 +- 2 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 packages/@sanity/cli/src/actions/mcp/__tests__/detectAvailableEditors.test.ts diff --git a/packages/@sanity/cli/src/actions/mcp/__tests__/detectAvailableEditors.test.ts b/packages/@sanity/cli/src/actions/mcp/__tests__/detectAvailableEditors.test.ts new file mode 100644 index 0000000000..28b89811b8 --- /dev/null +++ b/packages/@sanity/cli/src/actions/mcp/__tests__/detectAvailableEditors.test.ts @@ -0,0 +1,191 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' + +const mockCreateDetectionEnv = vi.hoisted(() => vi.fn()) +const mockExistsSync = vi.hoisted(() => vi.fn()) +const mockReadFile = vi.hoisted(() => vi.fn()) +const mockParseToml = vi.hoisted(() => vi.fn()) +const mockParseJsonc = vi.hoisted(() => vi.fn()) + +// Because EDITOR_CONFIGS are imported once, need to use doMock in each test to ensure we can manipulate the configs in each test separately. +function createFreshEditorConfigMocks(cfgs: Record) { + vi.doMock('../editorConfigs.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createDetectionEnv: mockCreateDetectionEnv, + get EDITOR_CONFIGS() { + return cfgs + }, + } + }) + + vi.doMock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + existsSync: mockExistsSync, + } + }) + + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readFile: mockReadFile, + } + }) + + vi.doMock('smol-toml', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + parse: mockParseToml, + } + }) + + vi.doMock('jsonc-parser', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + parse: mockParseJsonc, + } + }) +} + +const configPath = '/some/path' + +describe('mcp:detectAvailableEditors', () => { + beforeEach(() => { + // Reset modules to clear the require/import cache between tests - necessary to 'clear' the cached EDITOR_CONFIGS import + vi.resetModules() + mockExistsSync.mockReturnValue(true) + mockReadFile.mockResolvedValue('{}') + mockParseJsonc.mockReturnValue({}) + }) + afterEach(() => { + vi.clearAllMocks() + }) + + test('should filter out editor configs that return falsy config paths', async () => { + const detect = vi.fn().mockResolvedValue(false) + createFreshEditorConfigMocks({'very-complex-magic-8ball': {detect}}) + const {detectAvailableEditors} = await import('../detectAvailableEditors.js') + const res = await detectAvailableEditors() + + expect(detect).toHaveBeenCalled() + expect(res).toEqual([]) + }) + + test('should return editor configs with configured=false if config path does not exist', async () => { + mockExistsSync.mockReturnValue(false) + const name = 'stochastic-parrot' + const detect = vi.fn().mockResolvedValue(configPath) + createFreshEditorConfigMocks({[name]: {detect}}) + const {detectAvailableEditors} = await import('../detectAvailableEditors.js') + + const res = await detectAvailableEditors() + + expect(res).toEqual([{configPath, configured: false, name}]) + }) + + describe('parseable configs', () => { + test('should return editor config with configured=false if parsed config does not contain Sanity key under its configKey', async () => { + const name = 'sycophantic-ELIZA' + const configKey = 'secret' + const detect = vi.fn().mockReturnValue(configPath) + createFreshEditorConfigMocks({[name]: {configKey, detect, format: 'jsonc'}}) + mockParseJsonc.mockReturnValue({[configKey]: {}}) + const {detectAvailableEditors} = await import('../detectAvailableEditors.js') + + const res = await detectAvailableEditors() + + expect(res).toEqual([{configPath, configured: false, name}]) + }) + + test('should return editor config with configured=true and existingToken if parsed config contains Sanity key under its configKey and its readToken method returns something', async () => { + const name = 'slop-machine' + const configKey = 'secret' + const detect = vi.fn().mockReturnValue(configPath) + createFreshEditorConfigMocks({ + [name]: {configKey, detect, format: 'jsonc', readToken: () => 'token'}, + }) + mockParseJsonc.mockReturnValue({[configKey]: {Sanity: {}}}) + const {detectAvailableEditors} = await import('../detectAvailableEditors.js') + + const res = await detectAvailableEditors() + + expect(res).toEqual([{configPath, configured: true, existingToken: 'token', name}]) + }) + + test('should return editor config with configured=true if parsed config contains Sanity key under its configKey', async () => { + const name = 'slop-machine' + const configKey = 'secret' + const detect = vi.fn().mockReturnValue(configPath) + createFreshEditorConfigMocks({ + [name]: {configKey, detect, format: 'jsonc', readToken: () => undefined}, + }) + mockParseJsonc.mockReturnValue({[configKey]: {Sanity: {}}}) + const {detectAvailableEditors} = await import('../detectAvailableEditors.js') + + const res = await detectAvailableEditors() + + expect(res).toEqual([{configPath, configured: true, name}]) + }) + + test('should return editor config with configured=true and authStatus=valid if editor config contains oauthOnly and parsed config contains Sanity key under its configKey', async () => { + const name = 'slop-machine' + const configKey = 'secret' + const detect = vi.fn().mockReturnValue(configPath) + createFreshEditorConfigMocks({ + [name]: {configKey, detect, format: 'jsonc', oauthOnly: true, readToken: () => undefined}, + }) + mockParseJsonc.mockReturnValue({[configKey]: {Sanity: {}}}) + const {detectAvailableEditors} = await import('../detectAvailableEditors.js') + + const res = await detectAvailableEditors() + + expect(res).toEqual([{authStatus: 'valid', configPath, configured: true, name}]) + }) + }) + + describe('unparseable configs', () => { + test('should filter out editor TOML configs that return non-objects', async () => { + const name = 'humongous-regular-expression' + const detect = vi.fn().mockResolvedValue(configPath) + createFreshEditorConfigMocks({[name]: {detect, format: 'toml'}}) + mockParseToml.mockReturnValue([]) + const {detectAvailableEditors} = await import('../detectAvailableEditors.js') + + const res = await detectAvailableEditors() + + expect(detect).toHaveBeenCalled() + expect(res).toEqual([]) + }) + + test('should filter out editor TOML configs that throw upon parsing', async () => { + const name = 'forgetful-lying-robot' + const detect = vi.fn().mockReturnValue(configPath) + createFreshEditorConfigMocks({[name]: {detect, format: 'toml'}}) + mockParseToml.mockThrow('boom') + const {detectAvailableEditors} = await import('../detectAvailableEditors.js') + + const res = await detectAvailableEditors() + + expect(detect).toHaveBeenCalled() + expect(res).toEqual([]) + }) + + test('should filter out editor JSON configs that returns a non-object', async () => { + const name = 'spicy-autocomplete' + const detect = vi.fn().mockReturnValue(configPath) + createFreshEditorConfigMocks({[name]: {detect, format: 'jsonc'}}) + mockParseJsonc.mockReturnValue('not a config yo') + const {detectAvailableEditors} = await import('../detectAvailableEditors.js') + + const res = await detectAvailableEditors() + + expect(detect).toHaveBeenCalled() + expect(res).toEqual([]) + }) + }) +}) diff --git a/packages/@sanity/cli/src/actions/mcp/detectAvailableEditors.ts b/packages/@sanity/cli/src/actions/mcp/detectAvailableEditors.ts index e8e6f21755..6c1a4f0b4d 100644 --- a/packages/@sanity/cli/src/actions/mcp/detectAvailableEditors.ts +++ b/packages/@sanity/cli/src/actions/mcp/detectAvailableEditors.ts @@ -1,5 +1,5 @@ import {existsSync} from 'node:fs' -import fs from 'node:fs/promises' +import {readFile} from 'node:fs/promises' import {subdebug} from '@sanity/cli-core' import {type ParseError, parse as parseJsonc} from 'jsonc-parser' @@ -67,7 +67,7 @@ async function checkEditorConfig(name: EditorName, configPath: string): Promise< // Config exists - try to parse it try { - const content = await fs.readFile(configPath, 'utf8') + const content = await readFile(configPath, 'utf8') const config = parseConfig(content, format) if (config === null) { From 91fc00e09937db6c5f06dcb9e10553c8da6996d0 Mon Sep 17 00:00:00 2001 From: filmaj Date: Fri, 26 Jun 2026 18:00:04 -0400 Subject: [PATCH 6/7] test(chore): remove integration test now that detectAvailableEditors unit tests exist --- .../commands/mcp/configure.test.ts | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/packages/@sanity/cli/test/integration/commands/mcp/configure.test.ts b/packages/@sanity/cli/test/integration/commands/mcp/configure.test.ts index 3fe352fd96..2f70ea069d 100644 --- a/packages/@sanity/cli/test/integration/commands/mcp/configure.test.ts +++ b/packages/@sanity/cli/test/integration/commands/mcp/configure.test.ts @@ -121,29 +121,6 @@ describe.sequential('#mcp:configure', () => { expect(pending, 'pending mocks').toEqual([]) }) - // ------------------------------------------------------------------------- - // Codex CLI: unparseable TOML skips editor - // ------------------------------------------------------------------------- - - test('skips Codex CLI when existing TOML config is unparseable', async () => { - mockExeca.mockImplementation((async (command: string | URL) => { - if (command === 'codex') return EXECA_SUCCESS - throw new Error('Not installed') - }) as never) - - mockExistsSync.mockImplementation((p: PathLike) => { - const normalized = String(p).replaceAll('\\', '/') - return normalized.endsWith('/config.toml') - }) - mockReadFile.mockResolvedValue('[[[') - mockCheckbox.mockResolvedValue([]) - - await testCommand(ConfigureMcpCommand, []) - - expect(mockCheckbox).not.toHaveBeenCalled() - expect(mockWriteFile).not.toHaveBeenCalled() - }) - // ------------------------------------------------------------------------- // Edge cases and no-editor scenario // ------------------------------------------------------------------------- From e40dc40ba4f448cf80c240a719993f28686540df Mon Sep 17 00:00:00 2001 From: filmaj Date: Fri, 26 Jun 2026 20:14:51 -0400 Subject: [PATCH 7/7] test(chore): remove integration tests already covered by other unit tests --- .../actions/mcp/__tests__/setupMCP.test.ts | 42 +- .../commands/mcp/configure.test.ts | 444 ------------------ 2 files changed, 41 insertions(+), 445 deletions(-) delete mode 100644 packages/@sanity/cli/test/integration/commands/mcp/configure.test.ts diff --git a/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts b/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts index b89f7cde72..233381d256 100644 --- a/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts +++ b/packages/@sanity/cli/src/actions/mcp/__tests__/setupMCP.test.ts @@ -74,6 +74,22 @@ describe('setupMCP', () => { expect(mockReadSkillState).not.toHaveBeenCalled() }) + test('mcpMode: auto warns if no editors detected', async () => { + defaultMocks() + mockDetectAvailableEditors.mockResolvedValue([]) + + const result = await setupMCP({explicit: true, mode: 'auto', output: mockOutput}) + + expect(mockPromptForMCPSetup).not.toHaveBeenCalled() + expect(mockWriteMCPConfig).not.toHaveBeenCalled() + expect(result.configuredEditors).toEqual([]) + expect(result.skillsToInstall).toEqual([]) + expect(result.skipped).toBe(true) + expect(mockOutput.warn).toHaveBeenCalledWith( + expect.stringContaining(`Couldn't auto-configure Sanity MCP server for your editor`), + ) + }) + test('mcpMode: auto auto-selects actionable editors and writes configs', async () => { defaultMocks() mockDetectAvailableEditors.mockResolvedValue([ @@ -124,11 +140,14 @@ describe('setupMCP', () => { ] mockDetectAvailableEditors.mockResolvedValue(editors) - const result = await setupMCP({mode: 'auto', output: mockOutput}) + const result = await setupMCP({explicit: true, mode: 'auto', output: mockOutput}) expect(mockWriteMCPConfig).not.toHaveBeenCalled() expect(result.skipped).toBe(true) expect(result.alreadyConfiguredEditors).toEqual(['Cursor']) + expect(mockOutput.log).toHaveBeenCalledWith( + expect.stringContaining('All detected editors are already configured'), + ) }) // ------------------------------------------------------------------------- @@ -352,6 +371,27 @@ describe('setupMCP', () => { expect(mockOutput.warn).toHaveBeenCalledWith('Could not configure MCP for Cursor: disk full') }) + test('MCP token creation failure warns', async () => { + mockValidateEditorTokens.mockResolvedValue(undefined) + mockWriteMCPConfig.mockResolvedValue(undefined) + mockReadSkillState.mockResolvedValue({installedAgentDisplayNames: new Set()}) + // Only choose oauthonly=false editors here + mockDetectAvailableEditors.mockResolvedValue([editor({name: 'Cline'})]) + mockCreateMCPToken.mockRejectedValue('boom') + + const result = await setupMCP({mode: 'auto', output: mockOutput}) + + expect(mockPromptForMCPSetup).not.toHaveBeenCalled() + expect(mockWriteMCPConfig).not.toHaveBeenCalled() + expect(result.error).toBeInstanceOf(Error) + expect(result.configuredEditors).toEqual([]) + expect(result.skillsToInstall).toEqual([]) + expect(result.skipped).toBe(false) + expect(mockOutput.warn).toHaveBeenCalledWith( + expect.stringContaining('Could not configure MCP: boom'), + ) + }) + test('skill state probe failure → over-install (treat all as not installed)', async () => { defaultMocks() mockReadSkillState.mockResolvedValue({installedAgentDisplayNames: new Set()}) diff --git a/packages/@sanity/cli/test/integration/commands/mcp/configure.test.ts b/packages/@sanity/cli/test/integration/commands/mcp/configure.test.ts deleted file mode 100644 index 2f70ea069d..0000000000 --- a/packages/@sanity/cli/test/integration/commands/mcp/configure.test.ts +++ /dev/null @@ -1,444 +0,0 @@ -import {existsSync, type PathLike} from 'node:fs' -import fs from 'node:fs/promises' - -import {checkbox} from '@sanity/cli-core/ux' -import {convertToSystemPath, createTestToken, testCommand} from '@sanity/cli-test' -import {execa} from 'execa' -import {cleanAll, pendingMocks} from 'nock' -import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' - -import {ConfigureMcpCommand} from '../../../../src/commands/mcp/configure.js' - -const mockCreateMCPToken = vi.hoisted(() => vi.fn()) -const mockEnsureAuthenticated = vi.hoisted(() => vi.fn()) -const mockIsInteractive = vi.hoisted(() => vi.fn().mockReturnValue(true)) -const mockValidateMCPToken = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/actions/auth/ensureAuthenticated.js', async (importOriginal) => { - const actual = - await importOriginal() - return { - ...actual, - ensureAuthenticated: mockEnsureAuthenticated, - } -}) - -vi.mock('@sanity/cli-core', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - isInteractive: mockIsInteractive, - } -}) - -vi.mock('../../../../src/services/mcp.js', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - createMCPToken: mockCreateMCPToken, - validateMCPToken: mockValidateMCPToken, - } -}) - -vi.mock('@sanity/cli-core/ux', async () => { - const actual = await vi.importActual('@sanity/cli-core/ux') - return { - ...actual, - checkbox: vi.fn(), - } -}) - -vi.mock('node:fs', () => ({ - existsSync: vi.fn(), -})) - -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - default: { - mkdir: vi.fn(), - readFile: vi.fn(), - writeFile: vi.fn(), - }, - } -}) - -vi.mock('execa', () => ({ - execa: vi.fn(), -})) - -const mockCheckbox = vi.mocked(checkbox) -const mockExistsSync = vi.mocked(existsSync) -const mockReadFile = vi.mocked(fs.readFile) -const mockWriteFile = vi.mocked(fs.writeFile) -const mockExeca = vi.mocked(execa) - -function mockMCPTokenCreation(token: string): void { - mockCreateMCPToken.mockResolvedValueOnce(token) -} - -// --------------------------------------------------------------------------- -// Helpers for table-driven per-editor tests -// --------------------------------------------------------------------------- - -const EXECA_SUCCESS = { - command: 'test --version', - exitCode: 0, - failed: false, - killed: false, - signal: undefined, - stderr: '', - stdout: '1.0.0', - timedOut: false, -} as never - -// --------------------------------------------------------------------------- -// Main test suite -// --------------------------------------------------------------------------- - -describe.sequential('#mcp:configure', () => { - beforeEach(async () => { - mockEnsureAuthenticated.mockResolvedValue({ - email: 'test@example.com', - id: 'user-123', - name: 'Test User', - provider: 'github', - }) - mockExistsSync.mockReturnValue(false) - mockReadFile.mockResolvedValue('{}') - mockWriteFile.mockResolvedValue() - mockExeca.mockRejectedValue(new Error('Not installed')) - mockCreateMCPToken.mockReset() - mockValidateMCPToken.mockReset() - createTestToken('eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEyMyJ9.signature') - }) - - afterEach(() => { - vi.clearAllMocks() - const pending = pendingMocks() - cleanAll() - expect(pending, 'pending mocks').toEqual([]) - }) - - // ------------------------------------------------------------------------- - // Edge cases and no-editor scenario - // ------------------------------------------------------------------------- - - test('shows warning when no editors are detected', async () => { - mockExistsSync.mockReturnValue(false) - mockExeca.mockRejectedValue(new Error('Not installed')) - - const {error, stderr} = await testCommand(ConfigureMcpCommand, []) - - if (error) throw error - - expect(stderr).toContain("Couldn't auto-configure Sanity MCP server for your editor") - expect(stderr).toContain('https://mcp.sanity.io') - }) - - // ------------------------------------------------------------------------- - // Token lifecycle - // ------------------------------------------------------------------------- - - test('skips prompt when all configured editors have valid auth', async () => { - mockExistsSync.mockImplementation((path: PathLike) => { - return String(path).includes('.cursor') - }) - - mockReadFile.mockResolvedValue( - JSON.stringify({ - mcpServers: { - Sanity: { - headers: { - Authorization: 'Bearer existing-token', - }, - type: 'http', - url: 'https://mcp.sanity.io', - }, - }, - }), - ) - - mockValidateMCPToken.mockResolvedValueOnce(true) - - const {stdout} = await testCommand(ConfigureMcpCommand, []) - - // Should NOT prompt the user — everything is already configured - expect(mockCheckbox).not.toHaveBeenCalled() - expect(stdout).toContain('All detected editors are already configured') - }) - - test('skips prompt for oauthOnly editor configured without a bearer token', async () => { - mockExistsSync.mockImplementation((path: PathLike) => { - return String(path).includes('.cursor') - }) - - // Cursor config has Sanity entry but no Authorization header (pure OAuth) - mockReadFile.mockResolvedValue( - JSON.stringify({ - mcpServers: { - Sanity: { - type: 'http', - url: 'https://mcp.sanity.io', - }, - }, - }), - ) - - // No /users/me mock — oauthOnly editors with no token skip validation entirely - - const {stdout} = await testCommand(ConfigureMcpCommand, []) - - expect(mockCheckbox).not.toHaveBeenCalled() - expect(stdout).toContain('All detected editors are already configured') - }) - - test('shows auth expired annotation when configured token is invalid', async () => { - mockExistsSync.mockImplementation((path: PathLike) => { - return String(path).includes('.cursor') - }) - - mockReadFile.mockResolvedValue( - JSON.stringify({ - mcpServers: { - Sanity: { - headers: { - Authorization: 'Bearer expired-token', - }, - type: 'http', - url: 'https://mcp.sanity.io', - }, - }, - }), - ) - - mockValidateMCPToken.mockResolvedValueOnce(false) - - mockCheckbox.mockResolvedValue(['Cursor']) - - // Cursor is oauthOnly so no new token is created — config is rewritten without a token - - await testCommand(ConfigureMcpCommand, []) - - expect(mockCheckbox).toHaveBeenCalledWith({ - choices: [ - { - checked: true, - name: 'Cursor (auth expired)', - value: 'Cursor', - }, - ], - message: 'Configure Sanity MCP server?', - }) - }) - - test('reuses valid token from another editor instead of creating new one', async () => { - // Detect both Cursor (configured with valid token) and Gemini (unconfigured) - mockExistsSync.mockImplementation((path: PathLike) => { - const normalized = String(path).replaceAll('\\', '/') - return normalized.includes('/.cursor') || normalized.endsWith('/.gemini/settings.json') - }) - - // Cursor has existing config with valid token, Gemini has empty config - mockReadFile.mockImplementation(async (filePath: unknown) => { - if (String(filePath).includes('.cursor')) { - return JSON.stringify({ - mcpServers: { - Sanity: { - headers: {Authorization: 'Bearer valid-reusable-token'}, - type: 'http', - url: 'https://mcp.sanity.io', - }, - }, - }) - } - return '{}' - }) - - mockValidateMCPToken.mockResolvedValueOnce(true) - - // User selects only the unconfigured editor (Gemini CLI) - mockCheckbox.mockResolvedValue(['Gemini CLI']) - - // NO /auth/session/create or /auth/fetch mocks — token should be reused - - const {stdout} = await testCommand(ConfigureMcpCommand, []) - - // Should write config with the reused token - expect(mockWriteFile).toHaveBeenCalledWith( - expect.stringContaining(convertToSystemPath('.gemini/settings.json')), - expect.stringContaining('valid-reusable-token'), - 'utf8', - ) - - expect(stdout).toContain('MCP configured for Gemini CLI') - }) - - // ------------------------------------------------------------------------- - // User interaction - // ------------------------------------------------------------------------- - - test('exits gracefully when user deselects all editors', async () => { - mockExistsSync.mockImplementation((path: PathLike) => { - return String(path).includes('.cursor') - }) - - mockCheckbox.mockResolvedValue([]) - - const {stdout} = await testCommand(ConfigureMcpCommand, []) - - expect(stdout).toContain('MCP configuration skipped') - expect(mockWriteFile).not.toHaveBeenCalled() - }) - - test('configures multiple editors when selected', async () => { - mockExistsSync.mockReturnValue(true) - - mockCheckbox.mockResolvedValue(['Cursor', 'VS Code']) - - mockMCPTokenCreation('multi-token-123') - - const {stdout} = await testCommand(ConfigureMcpCommand, []) - - expect(mockWriteFile).toHaveBeenCalledTimes(2) - - expect(mockWriteFile).toHaveBeenCalledWith( - expect.stringContaining(convertToSystemPath('.cursor/mcp.json')), - expect.not.stringContaining('multi-token-123'), - 'utf8', - ) - expect(mockWriteFile).toHaveBeenCalledWith( - expect.stringContaining(convertToSystemPath('Code/User/mcp.json')), - expect.stringContaining('multi-token-123'), - 'utf8', - ) - - expect(stdout).toContain('MCP configured for Cursor, VS Code') - }) - - test('auto-selects all editors in non-interactive mode without prompting', async () => { - mockIsInteractive.mockReturnValue(false) - - mockExeca.mockImplementation((async (command: string | URL) => { - if (command === 'opencode') return EXECA_SUCCESS - throw new Error('Not installed') - }) as never) - - mockMCPTokenCreation('test-token-ci') - - const {stdout} = await testCommand(ConfigureMcpCommand, []) - - expect(mockCheckbox).not.toHaveBeenCalled() - expect(mockWriteFile).toHaveBeenCalledWith( - expect.stringContaining(convertToSystemPath('.config/opencode/opencode.json')), - expect.stringContaining('test-token-ci'), - 'utf8', - ) - expect(stdout).toContain('MCP configured for OpenCode') - }) - - // ------------------------------------------------------------------------- - // Error handling - // ------------------------------------------------------------------------- - - test('handles token creation error gracefully', async () => { - mockExeca.mockImplementation((async (command: string | URL) => { - if (command === 'opencode') return EXECA_SUCCESS - throw new Error('Not installed') - }) as never) - - mockCheckbox.mockResolvedValue(['OpenCode']) - - mockCreateMCPToken.mockRejectedValueOnce(new Error('Not authenticated')) - - const {stderr} = await testCommand(ConfigureMcpCommand, []) - - expect(stderr).toContain('Could not configure MCP') - expect(stderr).toContain('https://mcp.sanity.io') - expect(mockWriteFile).not.toHaveBeenCalled() - }) - - test('handles file write error gracefully', async () => { - mockExeca.mockImplementation((async (command: string | URL) => { - if (command === 'opencode') return EXECA_SUCCESS - throw new Error('Not installed') - }) as never) - - mockCheckbox.mockResolvedValue(['OpenCode']) - - mockMCPTokenCreation('token-write-error') - - mockWriteFile.mockRejectedValue(new Error('Permission denied')) - - const {stderr} = await testCommand(ConfigureMcpCommand, []) - - expect(stderr).toContain('Could not configure MCP') - expect(stderr).toContain('https://mcp.sanity.io') - }) - - test('suggests login when login fails', async () => { - const {LoginError} = await import('../../../../src/errors/LoginError.js') - mockEnsureAuthenticated.mockRejectedValue(new LoginError('No authentication providers found')) - - const {error} = await testCommand(ConfigureMcpCommand, []) - - expect(error).toBeInstanceOf(Error) - expect(error?.message).toContain('No authentication providers found') - expect(error?.message).toContain('Try running `sanity login`') - expect(error?.oclif?.exit).toBe(1) - }) - - test('shows raw error for network failures without suggesting login', async () => { - mockEnsureAuthenticated.mockRejectedValue(new Error('request timed out')) - - const {error} = await testCommand(ConfigureMcpCommand, []) - - expect(error).toBeInstanceOf(Error) - expect(error?.message).toContain('request timed out') - expect(error?.message).not.toContain('sanity login') - expect(error?.oclif?.exit).toBe(1) - }) - - // ------------------------------------------------------------------------- - // Config merging - // ------------------------------------------------------------------------- - - test('merges with existing config file', async () => { - mockExeca.mockImplementation((async (command: string | URL) => { - if (command === 'opencode') return EXECA_SUCCESS - throw new Error('Not installed') - }) as never) - - mockExistsSync.mockImplementation((p: PathLike) => { - return String(p).endsWith('opencode.json') - }) - - mockReadFile.mockResolvedValue( - JSON.stringify({ - mcp: { - OtherServer: { - type: 'stdio', - }, - }, - }), - ) - - mockCheckbox.mockResolvedValue(['OpenCode']) - - mockMCPTokenCreation('merge-token-123') - - await testCommand(ConfigureMcpCommand, []) - - expect(mockWriteFile).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('OtherServer'), - 'utf8', - ) - expect(mockWriteFile).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('Sanity'), - 'utf8', - ) - }) -})