diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fde5e692..75fabb20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,7 @@ concurrency: permissions: contents: read actions: read + pull-requests: write jobs: # -------------------------------------------------------------------------- @@ -61,9 +62,35 @@ jobs: working-directory: apps/desktop run: npm run typecheck - - name: Run unit tests + - name: Run unit tests with coverage + if: matrix.os == 'ubuntu-latest' working-directory: apps/desktop - run: npm run test + run: npm run test:coverage + + - name: Run unit tests + if: matrix.os != 'ubuntu-latest' + working-directory: apps/desktop + run: npm run test:unit + + - name: Run integration tests + working-directory: apps/desktop + run: npm run test:integration + + - name: Upload coverage report + if: matrix.os == 'ubuntu-latest' && always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: apps/desktop/coverage/ + retention-days: 14 + + - name: Coverage PR comment + if: matrix.os == 'ubuntu-latest' && github.event_name == 'pull_request' + uses: davelosert/vitest-coverage-report-action@v2 + with: + working-directory: apps/desktop + json-summary-path: coverage/coverage-summary.json + json-final-path: coverage/coverage-final.json - name: Build application working-directory: apps/desktop diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..64d11c68 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,62 @@ +# E2E Tests +# +# Runs Playwright E2E tests for the Electron desktop app on Linux. +# Ubuntu-only since Electron E2E is platform-agnostic (Chromium renderer). +# Non-blocking initially — separate from ci-complete gate while stabilizing. + +name: E2E + +on: + push: + branches: [main, develop] + paths: + - 'apps/**' + - '.github/workflows/e2e.yml' + pull_request: + branches: [main, develop] + paths: + - 'apps/**' + - '.github/workflows/e2e.yml' + +concurrency: + group: e2e-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + e2e: + name: E2E Tests + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js frontend + uses: ./.github/actions/setup-node-frontend + + - name: Install Playwright browsers + working-directory: apps/desktop + run: npx playwright install --with-deps chromium + + - name: Build application + working-directory: apps/desktop + run: npm run build + + - name: Run E2E tests + working-directory: apps/desktop + continue-on-error: true # Non-blocking while stabilizing — pre-existing __dirname ESM issue + run: xvfb-run --auto-servernum npm run test:e2e + + - name: Upload E2E report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-report + path: | + apps/desktop/e2e/playwright-report/ + apps/desktop/e2e/test-results/ + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57cc31a3..cb329926 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -572,9 +572,9 @@ jobs: IS_PRERELEASE="${{ steps.version.outputs.is_prerelease }}" if [ "$IS_PRERELEASE" = "true" ]; then - python3 scripts/update-readme.py "$VERSION" --prerelease + node scripts/update-readme.mjs "$VERSION" --prerelease else - python3 scripts/update-readme.py "$VERSION" + node scripts/update-readme.mjs "$VERSION" fi echo "--- Verifying update ---" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index bc1c0ad4..76c75f1b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -39,6 +39,8 @@ "start:packaged:win": "start \"\" \"dist\\win-unpacked\\Auto-Claude.exe\"", "start:packaged:linux": "./dist/linux-unpacked/auto-claude", "test": "vitest run", + "test:unit": "vitest run --exclude src/__tests__/integration/ --exclude src/__tests__/e2e/", + "test:integration": "vitest run src/__tests__/integration/", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:e2e": "npx playwright test --config=e2e/playwright.config.ts", @@ -135,6 +137,7 @@ "@types/semver": "^7.7.1", "@types/uuid": "^11.0.0", "@vitejs/plugin-react": "^5.1.2", + "@vitest/coverage-v8": "^4.1.0", "autoprefixer": "^10.4.22", "cross-env": "^10.1.0", "electron": "40.0.0", diff --git a/apps/desktop/src/main/ai/auth/__tests__/resolver.test.ts b/apps/desktop/src/main/ai/auth/__tests__/resolver.test.ts new file mode 100644 index 00000000..458dd254 --- /dev/null +++ b/apps/desktop/src/main/ai/auth/__tests__/resolver.test.ts @@ -0,0 +1,507 @@ +/** + * Tests for AI Auth Resolver + * + * Validates the multi-stage credential resolution fallback chain, + * provider account resolution, settings accessor registration, + * environment variable fallback, and Z.AI endpoint routing. + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; + +// Mock token-refresh before importing resolver +// Path resolution from src/main/ai/auth/__tests__/: +// ../ = src/main/ai/auth/ +// ../../ = src/main/ai/ +// ../../../ = src/main/ +// So ../../../claude-profile/ = src/main/claude-profile/ +vi.mock('../../../claude-profile/token-refresh', () => ({ + ensureValidToken: vi.fn(), + reactiveTokenRefresh: vi.fn(), +})); + +// Mock profile-scorer +vi.mock('../../../claude-profile/profile-scorer', () => ({ + scoreProviderAccount: vi.fn(), +})); + +// Mock model equivalence +// ../../../../shared/ = src/shared/ (4 levels up from __tests__ = src/) +vi.mock('../../../../shared/constants/models', () => ({ + resolveModelEquivalent: vi.fn(), +})); + +// Mock provider factory detection +// ../../providers/ = src/main/ai/providers/ +vi.mock('../../providers/factory', () => ({ + detectProviderFromModel: vi.fn(), +})); + +import { ensureValidToken, reactiveTokenRefresh } from '../../../claude-profile/token-refresh'; +import { scoreProviderAccount } from '../../../claude-profile/profile-scorer'; +import { resolveModelEquivalent } from '../../../../shared/constants/models'; +import { detectProviderFromModel } from '../../providers/factory'; +import { + resolveAuth, + hasCredentials, + registerSettingsAccessor, + refreshOAuthTokenReactive, + resolveAuthFromQueue, + buildDefaultQueueConfig, +} from '../resolver'; + +const mockEnsureValidToken = vi.mocked(ensureValidToken); +const mockReactiveTokenRefresh = vi.mocked(reactiveTokenRefresh); +const mockScoreProviderAccount = vi.mocked(scoreProviderAccount); +const mockResolveModelEquivalent = vi.mocked(resolveModelEquivalent); +const _mockDetectProviderFromModel = vi.mocked(detectProviderFromModel); + +// Helper: reset the module-level settings accessor between tests +function clearSettingsAccessor() { + registerSettingsAccessor(() => undefined); +} + +beforeEach(() => { + vi.clearAllMocks(); + clearSettingsAccessor(); + // Clean up any environment variable side effects + delete process.env.ANTHROPIC_API_KEY; + delete process.env.OPENAI_API_KEY; + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.OPENAI_BASE_URL; +}); + +afterEach(() => { + delete process.env.ANTHROPIC_API_KEY; + delete process.env.OPENAI_API_KEY; + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.OPENAI_BASE_URL; +}); + +// ============================================================================= +// registerSettingsAccessor +// ============================================================================= + +describe('registerSettingsAccessor', () => { + it('wires up settings so subsequent calls read from the accessor', async () => { + registerSettingsAccessor((key) => (key === 'globalAnthropicApiKey' ? 'sk-from-settings' : undefined)); + + const auth = await resolveAuth({ provider: 'anthropic' }); + expect(auth).not.toBeNull(); + expect(auth?.apiKey).toBe('sk-from-settings'); + expect(auth?.source).toBe('profile-api-key'); + }); +}); + +// ============================================================================= +// Stage 1: Profile OAuth Token +// ============================================================================= + +describe('resolveAuth — Stage 1: Profile OAuth', () => { + it('returns oauth token for anthropic when ensureValidToken resolves', async () => { + mockEnsureValidToken.mockResolvedValueOnce({ token: 'oauth-token-abc', wasRefreshed: false }); + + const auth = await resolveAuth({ provider: 'anthropic', configDir: '/home/.config/claude' }); + + expect(auth).not.toBeNull(); + expect(auth?.apiKey).toBe('oauth-token-abc'); + expect(auth?.source).toBe('profile-oauth'); + expect(auth?.headers).toMatchObject({ 'anthropic-beta': expect.stringContaining('oauth') }); + }); + + it('includes custom base URL when ANTHROPIC_BASE_URL is set', async () => { + process.env.ANTHROPIC_BASE_URL = 'https://proxy.example.com'; + mockEnsureValidToken.mockResolvedValueOnce({ token: 'oauth-token-abc', wasRefreshed: false }); + + const auth = await resolveAuth({ provider: 'anthropic' }); + + expect(auth?.baseURL).toBe('https://proxy.example.com'); + }); + + it('skips oauth stage for non-anthropic providers', async () => { + // openai has no oauth stage; should fall through to environment + process.env.OPENAI_API_KEY = 'sk-env-openai'; + + const auth = await resolveAuth({ provider: 'openai' }); + + expect(mockEnsureValidToken).not.toHaveBeenCalled(); + expect(auth?.source).toBe('environment'); + }); + + it('falls through when ensureValidToken throws', async () => { + mockEnsureValidToken.mockRejectedValueOnce(new Error('keychain locked')); + process.env.ANTHROPIC_API_KEY = 'sk-env-fallback'; + + const auth = await resolveAuth({ provider: 'anthropic' }); + + expect(auth?.apiKey).toBe('sk-env-fallback'); + expect(auth?.source).toBe('environment'); + }); + + it('falls through when ensureValidToken returns no token', async () => { + mockEnsureValidToken.mockResolvedValueOnce({ token: null, wasRefreshed: false }); + process.env.ANTHROPIC_API_KEY = 'sk-env-fallback'; + + const auth = await resolveAuth({ provider: 'anthropic' }); + + expect(auth?.source).toBe('environment'); + }); +}); + +// ============================================================================= +// Stage 2: Profile API Key (from settings) +// ============================================================================= + +describe('resolveAuth — Stage 2: Profile API Key', () => { + it('returns api-key from settings when no oauth token available', async () => { + mockEnsureValidToken.mockResolvedValueOnce({ token: null, wasRefreshed: false }); + registerSettingsAccessor((key) => (key === 'globalAnthropicApiKey' ? 'sk-settings-key' : undefined)); + + const auth = await resolveAuth({ provider: 'anthropic' }); + + expect(auth?.apiKey).toBe('sk-settings-key'); + expect(auth?.source).toBe('profile-api-key'); + }); + + it('includes base URL from environment even for settings-based keys', async () => { + process.env.ANTHROPIC_BASE_URL = 'https://custom.proxy.io'; + mockEnsureValidToken.mockResolvedValueOnce({ token: null, wasRefreshed: false }); + registerSettingsAccessor((key) => (key === 'globalAnthropicApiKey' ? 'sk-settings' : undefined)); + + const auth = await resolveAuth({ provider: 'anthropic' }); + + expect(auth?.baseURL).toBe('https://custom.proxy.io'); + }); + + it('returns null from settings stage when accessor returns nothing', async () => { + mockEnsureValidToken.mockResolvedValueOnce({ token: null, wasRefreshed: false }); + // settings accessor returns undefined for everything, env also not set + const auth = await resolveAuth({ provider: 'anthropic' }); + expect(auth).toBeNull(); + }); +}); + +// ============================================================================= +// Stage 3: Environment Variable +// ============================================================================= + +describe('resolveAuth — Stage 3: Environment Variable', () => { + it('returns env key for openai', async () => { + process.env.OPENAI_API_KEY = 'sk-env-openai-123'; + + const auth = await resolveAuth({ provider: 'openai' }); + + expect(auth?.apiKey).toBe('sk-env-openai-123'); + expect(auth?.source).toBe('environment'); + }); + + it('includes base URL from env when OPENAI_BASE_URL is set', async () => { + process.env.OPENAI_API_KEY = 'sk-env-openai'; + process.env.OPENAI_BASE_URL = 'https://openai-proxy.com'; + + const auth = await resolveAuth({ provider: 'openai' }); + + expect(auth?.baseURL).toBe('https://openai-proxy.com'); + }); + + it('returns null for bedrock (no env var defined)', async () => { + const auth = await resolveAuth({ provider: 'bedrock' }); + expect(auth).toBeNull(); + }); +}); + +// ============================================================================= +// Stage 4: Default Credentials (no-auth providers) +// ============================================================================= + +describe('resolveAuth — Stage 4: Default Credentials', () => { + it('returns empty api key for ollama', async () => { + const auth = await resolveAuth({ provider: 'ollama' }); + + expect(auth).not.toBeNull(); + expect(auth?.apiKey).toBe(''); + expect(auth?.source).toBe('default'); + }); + + it('returns null for unknown provider with no credentials', async () => { + const auth = await resolveAuth({ provider: 'groq' }); + expect(auth).toBeNull(); + }); +}); + +// ============================================================================= +// hasCredentials +// ============================================================================= + +describe('hasCredentials', () => { + it('returns true when credentials resolve', async () => { + process.env.OPENAI_API_KEY = 'sk-test'; + expect(await hasCredentials({ provider: 'openai' })).toBe(true); + }); + + it('returns true for ollama (no-auth)', async () => { + expect(await hasCredentials({ provider: 'ollama' })).toBe(true); + }); + + it('returns false when no credentials available', async () => { + expect(await hasCredentials({ provider: 'groq' })).toBe(false); + }); +}); + +// ============================================================================= +// refreshOAuthTokenReactive +// ============================================================================= + +describe('refreshOAuthTokenReactive', () => { + it('returns new token from reactiveTokenRefresh', async () => { + mockReactiveTokenRefresh.mockResolvedValueOnce({ token: 'refreshed-token-xyz', wasRefreshed: true }); + + const result = await refreshOAuthTokenReactive('/some/config/dir'); + + expect(result).toBe('refreshed-token-xyz'); + expect(mockReactiveTokenRefresh).toHaveBeenCalledWith('/some/config/dir'); + }); + + it('returns null when reactiveTokenRefresh returns no token', async () => { + mockReactiveTokenRefresh.mockResolvedValueOnce({ token: null, wasRefreshed: false }); + + const result = await refreshOAuthTokenReactive(undefined); + + expect(result).toBeNull(); + }); + + it('returns null when reactiveTokenRefresh throws', async () => { + mockReactiveTokenRefresh.mockRejectedValueOnce(new Error('network error')); + + const result = await refreshOAuthTokenReactive('/config'); + + expect(result).toBeNull(); + }); +}); + +// ============================================================================= +// Provider Account Resolution (Stage 0) +// ============================================================================= + +describe('resolveAuth — Stage 0: Provider Account', () => { + it('returns api-key auth from providerAccounts setting', async () => { + const accounts = [ + { + provider: 'openai', + isActive: true, + authType: 'api-key', + apiKey: 'sk-provider-account-key', + }, + ]; + registerSettingsAccessor((key) => { + if (key === 'providerAccounts') return JSON.stringify(accounts); + return undefined; + }); + + const auth = await resolveAuth({ provider: 'openai' }); + + expect(auth?.apiKey).toBe('sk-provider-account-key'); + expect(auth?.source).toBe('profile-api-key'); + }); + + it('routes z.ai subscription to coding API endpoint', async () => { + const accounts = [ + { + provider: 'zai', + isActive: true, + authType: 'api-key', + apiKey: 'zhipu-key', + billingModel: 'subscription', + }, + ]; + registerSettingsAccessor((key) => { + if (key === 'providerAccounts') return JSON.stringify(accounts); + return undefined; + }); + + const auth = await resolveAuth({ provider: 'zai' }); + + expect(auth?.apiKey).toBe('zhipu-key'); + expect(auth?.baseURL).toContain('/coding/paas/v4'); + }); + + it('routes z.ai pay-per-use to general API endpoint', async () => { + const accounts = [ + { + provider: 'zai', + isActive: true, + authType: 'api-key', + apiKey: 'zhipu-key', + billingModel: 'pay-per-use', + }, + ]; + registerSettingsAccessor((key) => { + if (key === 'providerAccounts') return JSON.stringify(accounts); + return undefined; + }); + + const auth = await resolveAuth({ provider: 'zai' }); + + expect(auth?.baseURL).toContain('/paas/v4'); + expect(auth?.baseURL).not.toContain('/coding/'); + }); + + it('skips inactive accounts and falls through', async () => { + const accounts = [ + { provider: 'openai', isActive: false, authType: 'api-key', apiKey: 'sk-inactive' }, + ]; + registerSettingsAccessor((key) => { + if (key === 'providerAccounts') return JSON.stringify(accounts); + return undefined; + }); + process.env.OPENAI_API_KEY = 'sk-env-fallback'; + + const auth = await resolveAuth({ provider: 'openai' }); + + expect(auth?.source).toBe('environment'); + }); + + it('handles malformed providerAccounts JSON gracefully', async () => { + registerSettingsAccessor((key) => { + if (key === 'providerAccounts') return 'not-valid-json{{'; + return undefined; + }); + process.env.OPENAI_API_KEY = 'sk-fallback'; + + const auth = await resolveAuth({ provider: 'openai' }); + expect(auth?.source).toBe('environment'); + }); +}); + +// ============================================================================= +// resolveAuthFromQueue +// ============================================================================= + +describe('resolveAuthFromQueue', () => { + const baseAccount = { + id: 'acc-1', + provider: 'anthropic' as const, + authType: 'api-key' as const, + apiKey: 'sk-queue-key', + isActive: true, + name: 'Primary Account', + billingModel: 'pay-per-use' as const, + createdAt: 0, + updatedAt: 0, + }; + + beforeEach(() => { + mockScoreProviderAccount.mockReturnValue({ available: true, score: 100 }); + mockResolveModelEquivalent.mockReturnValue({ + modelId: 'claude-sonnet-4-5-20250929', + reasoning: { type: 'none' }, + }); + }); + + it('resolves auth from the first available account in queue', async () => { + const result = await resolveAuthFromQueue('sonnet', [baseAccount]); + + expect(result).not.toBeNull(); + expect(result?.accountId).toBe('acc-1'); + expect(result?.apiKey).toBe('sk-queue-key'); + expect(result?.resolvedProvider).toBe('anthropic'); + }); + + it('skips excluded account IDs', async () => { + const result = await resolveAuthFromQueue('sonnet', [baseAccount], { + excludeAccountIds: ['acc-1'], + }); + + expect(result).toBeNull(); + }); + + it('skips unavailable accounts', async () => { + mockScoreProviderAccount.mockReturnValueOnce({ available: false, score: 0 }); + + const result = await resolveAuthFromQueue('sonnet', [baseAccount]); + + expect(result).toBeNull(); + }); + + it('returns null when queue is empty', async () => { + const result = await resolveAuthFromQueue('sonnet', []); + expect(result).toBeNull(); + }); + + it('uses the resolved model ID from equivalence table', async () => { + mockResolveModelEquivalent.mockReturnValueOnce({ + modelId: 'claude-haiku-4-5', + reasoning: { type: 'none' }, + }); + + const result = await resolveAuthFromQueue('haiku', [baseAccount]); + + expect(result?.resolvedModelId).toBe('claude-haiku-4-5'); + }); + + it('falls through to next account when first has no credentials', async () => { + const noKeyAccount = { ...baseAccount, id: 'acc-no-key', apiKey: undefined, authType: 'api-key' as const }; + const goodAccount = { ...baseAccount, id: 'acc-2' }; + + const result = await resolveAuthFromQueue('sonnet', [noKeyAccount, goodAccount]); + + expect(result?.accountId).toBe('acc-2'); + }); +}); + +// ============================================================================= +// buildDefaultQueueConfig +// ============================================================================= + +describe('buildDefaultQueueConfig', () => { + it('returns undefined when no settings accessor is registered', () => { + // accessor returns undefined for everything + const result = buildDefaultQueueConfig('claude-sonnet-4-5-20250929'); + expect(result).toBeUndefined(); + }); + + it('returns sorted queue when providerAccounts are configured', () => { + const accounts = [ + { id: 'b', provider: 'openai', isActive: true }, + { id: 'a', provider: 'anthropic', isActive: true }, + ]; + const priorityOrder = ['a', 'b']; + + registerSettingsAccessor((key) => { + if (key === 'providerAccounts') return JSON.stringify(accounts); + if (key === 'globalPriorityOrder') return JSON.stringify(priorityOrder); + return undefined; + }); + + const result = buildDefaultQueueConfig('claude-sonnet-4-5-20250929'); + + expect(result).not.toBeUndefined(); + expect(result?.queue[0].id).toBe('a'); + expect(result?.queue[1].id).toBe('b'); + }); + + it('returns undefined when providerAccounts is empty array', () => { + registerSettingsAccessor((key) => { + if (key === 'providerAccounts') return JSON.stringify([]); + return undefined; + }); + + const result = buildDefaultQueueConfig('sonnet'); + expect(result).toBeUndefined(); + }); + + it('returns accounts in natural order when no priority order is set', () => { + const accounts = [ + { id: 'x', provider: 'groq', isActive: true }, + { id: 'y', provider: 'mistral', isActive: true }, + ]; + registerSettingsAccessor((key) => { + if (key === 'providerAccounts') return JSON.stringify(accounts); + return undefined; + }); + + const result = buildDefaultQueueConfig('some-model'); + + expect(result?.queue[0].id).toBe('x'); + expect(result?.queue[1].id).toBe('y'); + }); +}); diff --git a/apps/desktop/src/main/ai/auth/__tests__/types.test.ts b/apps/desktop/src/main/ai/auth/__tests__/types.test.ts new file mode 100644 index 00000000..065b2ca6 --- /dev/null +++ b/apps/desktop/src/main/ai/auth/__tests__/types.test.ts @@ -0,0 +1,125 @@ +/** + * Tests for AI Auth Types + * + * Validates that exported constants have the correct mappings + * for environment variables, settings keys, and base URL env vars. + */ + +import { describe, expect, it } from 'vitest'; +import { + PROVIDER_ENV_VARS, + PROVIDER_SETTINGS_KEY, + PROVIDER_BASE_URL_ENV, +} from '../types'; + +describe('PROVIDER_ENV_VARS', () => { + it('maps anthropic to ANTHROPIC_API_KEY', () => { + expect(PROVIDER_ENV_VARS.anthropic).toBe('ANTHROPIC_API_KEY'); + }); + + it('maps openai to OPENAI_API_KEY', () => { + expect(PROVIDER_ENV_VARS.openai).toBe('OPENAI_API_KEY'); + }); + + it('maps google to GOOGLE_GENERATIVE_AI_API_KEY', () => { + expect(PROVIDER_ENV_VARS.google).toBe('GOOGLE_GENERATIVE_AI_API_KEY'); + }); + + it('maps bedrock to undefined (uses AWS credential chain)', () => { + expect(PROVIDER_ENV_VARS.bedrock).toBeUndefined(); + }); + + it('maps azure to AZURE_OPENAI_API_KEY', () => { + expect(PROVIDER_ENV_VARS.azure).toBe('AZURE_OPENAI_API_KEY'); + }); + + it('maps mistral to MISTRAL_API_KEY', () => { + expect(PROVIDER_ENV_VARS.mistral).toBe('MISTRAL_API_KEY'); + }); + + it('maps groq to GROQ_API_KEY', () => { + expect(PROVIDER_ENV_VARS.groq).toBe('GROQ_API_KEY'); + }); + + it('maps xai to XAI_API_KEY', () => { + expect(PROVIDER_ENV_VARS.xai).toBe('XAI_API_KEY'); + }); + + it('maps openrouter to OPENROUTER_API_KEY', () => { + expect(PROVIDER_ENV_VARS.openrouter).toBe('OPENROUTER_API_KEY'); + }); + + it('maps zai to ZHIPU_API_KEY', () => { + expect(PROVIDER_ENV_VARS.zai).toBe('ZHIPU_API_KEY'); + }); + + it('maps ollama to undefined (no auth required)', () => { + expect(PROVIDER_ENV_VARS.ollama).toBeUndefined(); + }); +}); + +describe('PROVIDER_SETTINGS_KEY', () => { + it('maps anthropic to globalAnthropicApiKey', () => { + expect(PROVIDER_SETTINGS_KEY.anthropic).toBe('globalAnthropicApiKey'); + }); + + it('maps openai to globalOpenAIApiKey', () => { + expect(PROVIDER_SETTINGS_KEY.openai).toBe('globalOpenAIApiKey'); + }); + + it('maps google to globalGoogleApiKey', () => { + expect(PROVIDER_SETTINGS_KEY.google).toBe('globalGoogleApiKey'); + }); + + it('maps groq to globalGroqApiKey', () => { + expect(PROVIDER_SETTINGS_KEY.groq).toBe('globalGroqApiKey'); + }); + + it('maps mistral to globalMistralApiKey', () => { + expect(PROVIDER_SETTINGS_KEY.mistral).toBe('globalMistralApiKey'); + }); + + it('maps xai to globalXAIApiKey', () => { + expect(PROVIDER_SETTINGS_KEY.xai).toBe('globalXAIApiKey'); + }); + + it('maps azure to globalAzureApiKey', () => { + expect(PROVIDER_SETTINGS_KEY.azure).toBe('globalAzureApiKey'); + }); + + it('maps openrouter to globalOpenRouterApiKey', () => { + expect(PROVIDER_SETTINGS_KEY.openrouter).toBe('globalOpenRouterApiKey'); + }); + + it('maps zai to globalZAIApiKey', () => { + expect(PROVIDER_SETTINGS_KEY.zai).toBe('globalZAIApiKey'); + }); + + it('does not have a key for bedrock', () => { + expect(PROVIDER_SETTINGS_KEY.bedrock).toBeUndefined(); + }); + + it('does not have a key for ollama', () => { + expect(PROVIDER_SETTINGS_KEY.ollama).toBeUndefined(); + }); +}); + +describe('PROVIDER_BASE_URL_ENV', () => { + it('maps anthropic to ANTHROPIC_BASE_URL', () => { + expect(PROVIDER_BASE_URL_ENV.anthropic).toBe('ANTHROPIC_BASE_URL'); + }); + + it('maps openai to OPENAI_BASE_URL', () => { + expect(PROVIDER_BASE_URL_ENV.openai).toBe('OPENAI_BASE_URL'); + }); + + it('maps azure to AZURE_OPENAI_ENDPOINT', () => { + expect(PROVIDER_BASE_URL_ENV.azure).toBe('AZURE_OPENAI_ENDPOINT'); + }); + + it('does not define base URL env for other providers', () => { + expect(PROVIDER_BASE_URL_ENV.google).toBeUndefined(); + expect(PROVIDER_BASE_URL_ENV.groq).toBeUndefined(); + expect(PROVIDER_BASE_URL_ENV.mistral).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/main/ai/client/__tests__/factory.test.ts b/apps/desktop/src/main/ai/client/__tests__/factory.test.ts new file mode 100644 index 00000000..608dd4a9 --- /dev/null +++ b/apps/desktop/src/main/ai/client/__tests__/factory.test.ts @@ -0,0 +1,329 @@ +/** + * Tests for Client Factory + * + * Validates createSimpleClient() and createAgentClient() — model resolution, + * credential wiring, tool registry binding, queue-based auth, and cleanup. + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +// Mock auth resolver — inline to avoid hoisting issues +vi.mock('../../auth/resolver', () => ({ + resolveAuth: vi.fn().mockResolvedValue({ apiKey: 'sk-default', source: 'environment' }), + resolveAuthFromQueue: vi.fn().mockResolvedValue(null), + buildDefaultQueueConfig: vi.fn().mockReturnValue(undefined), +})); + +// Mock provider factory — inline +vi.mock('../../providers/factory', () => ({ + createProvider: vi.fn().mockReturnValue({ type: 'language-model', modelId: 'mock-model-id' }), + detectProviderFromModel: vi.fn().mockReturnValue('anthropic'), +})); + +// Mock phase config — inline +vi.mock('../../config/phase-config', () => ({ + resolveModelId: vi.fn().mockReturnValue('claude-haiku-4-5'), +})); + +// Mock agent configs — inline +vi.mock('../../config/agent-configs', () => ({ + getDefaultThinkingLevel: vi.fn().mockReturnValue('medium'), + getRequiredMcpServers: vi.fn().mockReturnValue([]), +})); + +// Mock MCP client module — inline +vi.mock('../../mcp/client', () => ({ + createMcpClientsForAgent: vi.fn().mockResolvedValue([]), + closeAllMcpClients: vi.fn().mockResolvedValue(undefined), + mergeMcpTools: vi.fn().mockReturnValue({}), +})); + +// Mock tool registry — inline +vi.mock('../../tools/build-registry', () => ({ + buildToolRegistry: vi.fn().mockReturnValue({ + getToolsForAgent: vi.fn().mockReturnValue({ Read: {}, Write: {} }), + }), +})); + +// Mock config/types resolveReasoningParams — inline +vi.mock('../../config/types', () => ({ + resolveReasoningParams: vi.fn().mockReturnValue({}), +})); + +import { resolveAuth, resolveAuthFromQueue, buildDefaultQueueConfig } from '../../auth/resolver'; +import { createProvider, detectProviderFromModel } from '../../providers/factory'; +import { resolveModelId } from '../../config/phase-config'; +import { getDefaultThinkingLevel, getRequiredMcpServers } from '../../config/agent-configs'; +import { createMcpClientsForAgent, closeAllMcpClients, mergeMcpTools } from '../../mcp/client'; +import { buildToolRegistry } from '../../tools/build-registry'; +import { createSimpleClient, createAgentClient } from '../factory'; +import type { LanguageModel, Tool } from 'ai'; +import type { ToolContext } from '../../tools/types'; +import type { AgentClientConfig } from '../types'; +import type { ProviderAccount } from '../../../../shared/types/provider-account'; +import type { McpClientResult } from '../../mcp/types'; +import type { ToolRegistry } from '../../tools/registry'; + +const mockResolveAuth = vi.mocked(resolveAuth); +const mockResolveAuthFromQueue = vi.mocked(resolveAuthFromQueue); +const mockBuildDefaultQueueConfig = vi.mocked(buildDefaultQueueConfig); +const mockCreateProvider = vi.mocked(createProvider); +const mockDetectProviderFromModel = vi.mocked(detectProviderFromModel); +const mockResolveModelId = vi.mocked(resolveModelId); +const mockGetDefaultThinkingLevel = vi.mocked(getDefaultThinkingLevel); +const mockGetRequiredMcpServers = vi.mocked(getRequiredMcpServers); +const mockCreateMcpClientsForAgent = vi.mocked(createMcpClientsForAgent); +const mockCloseAllMcpClients = vi.mocked(closeAllMcpClients); +const mockMergeMcpTools = vi.mocked(mergeMcpTools); +const mockBuildToolRegistry = vi.mocked(buildToolRegistry); + +const FAKE_MODEL = { type: 'language-model', modelId: 'mock-model-id' }; + +const baseToolContext = { + cwd: '/project', + projectDir: '/project', + specDir: '/project/.auto-claude/specs/001', + securityProfile: 'standard' as const, +} as unknown as ToolContext; + +beforeEach(() => { + vi.clearAllMocks(); + + // Re-establish defaults after clearAllMocks + mockResolveAuth.mockResolvedValue({ apiKey: 'sk-default', source: 'environment' }); + mockResolveAuthFromQueue.mockResolvedValue(null); + mockBuildDefaultQueueConfig.mockReturnValue(undefined); + mockCreateProvider.mockReturnValue(FAKE_MODEL as unknown as LanguageModel); + mockDetectProviderFromModel.mockReturnValue('anthropic'); + mockResolveModelId.mockReturnValue('claude-haiku-4-5'); + mockGetDefaultThinkingLevel.mockReturnValue('medium'); + mockGetRequiredMcpServers.mockReturnValue([]); + mockCreateMcpClientsForAgent.mockResolvedValue([]); + mockCloseAllMcpClients.mockResolvedValue(undefined); + mockMergeMcpTools.mockReturnValue({}); + + // ToolRegistry mock: getToolsForAgent returns a basic tools map + const mockRegistry = { getToolsForAgent: vi.fn().mockReturnValue({ Read: {}, Write: {} }) }; + mockBuildToolRegistry.mockReturnValue(mockRegistry as unknown as ToolRegistry); +}); + +// ============================================================================= +// createSimpleClient +// ============================================================================= + +describe('createSimpleClient', () => { + it('returns model, resolvedModelId, tools, systemPrompt, maxSteps, and thinkingLevel', async () => { + const result = await createSimpleClient({ systemPrompt: 'You are helpful.' }); + + expect(result.model).toBe(FAKE_MODEL); + expect(result.resolvedModelId).toBeDefined(); + expect(result.tools).toBeDefined(); + expect(result.systemPrompt).toBe('You are helpful.'); + expect(result.maxSteps).toBe(1); + expect(result.thinkingLevel).toBe('low'); + }); + + it('defaults modelShorthand to haiku when not specified', async () => { + await createSimpleClient({ systemPrompt: 'Test' }); + expect(mockResolveModelId).toHaveBeenCalledWith('haiku'); + }); + + it('uses the specified modelShorthand', async () => { + await createSimpleClient({ systemPrompt: 'Test', modelShorthand: 'sonnet' }); + expect(mockResolveModelId).toHaveBeenCalledWith('sonnet'); + }); + + it('uses the specified thinkingLevel', async () => { + const result = await createSimpleClient({ systemPrompt: 'Test', thinkingLevel: 'high' }); + expect(result.thinkingLevel).toBe('high'); + }); + + it('uses specified maxSteps', async () => { + const result = await createSimpleClient({ systemPrompt: 'Test', maxSteps: 5 }); + expect(result.maxSteps).toBe(5); + }); + + it('wires resolved auth credentials into createProvider', async () => { + mockResolveAuth.mockResolvedValueOnce({ + apiKey: 'sk-resolved', + source: 'environment', + baseURL: 'https://custom.api.com', + }); + + await createSimpleClient({ systemPrompt: 'Test' }); + + expect(mockCreateProvider).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + apiKey: 'sk-resolved', + baseURL: 'https://custom.api.com', + }), + }), + ); + }); + + it('passes tools option through to result', async () => { + const customTools = { myTool: {} as unknown as Tool }; + const result = await createSimpleClient({ systemPrompt: 'Test', tools: customTools }); + expect(result.tools).toBe(customTools); + }); + + it('uses queue-based resolution when queueConfig is provided', async () => { + const queueAuth = { + apiKey: 'sk-queue', + source: 'profile-api-key' as const, + accountId: 'acc-1', + resolvedProvider: 'anthropic' as const, + resolvedModelId: 'claude-opus-4-6', + reasoningConfig: { type: 'none' as const }, + }; + mockResolveAuthFromQueue.mockResolvedValueOnce(queueAuth); + + const queueConfig = { + queue: [{ id: 'acc-1' } as unknown as ProviderAccount], + requestedModel: 'claude-opus-4-6', + }; + + const result = await createSimpleClient({ systemPrompt: 'Test', queueConfig }); + + expect(mockResolveAuthFromQueue).toHaveBeenCalled(); + expect(result.queueAuth).toBe(queueAuth); + expect(result.resolvedModelId).toBe('claude-opus-4-6'); + }); + + it('throws when queueConfig is provided but no account is available', async () => { + mockResolveAuthFromQueue.mockResolvedValueOnce(null); + + const queueConfig = { queue: [], requestedModel: 'sonnet' }; + + await expect( + createSimpleClient({ systemPrompt: 'Test', queueConfig }), + ).rejects.toThrow('No available account in priority queue'); + }); +}); + +// ============================================================================= +// createAgentClient +// ============================================================================= + +describe('createAgentClient', () => { + const baseConfig = { + agentType: 'coder' as const, + systemPrompt: 'You are a coder.', + toolContext: baseToolContext, + phase: 'coding' as const, + }; + + it('returns model, tools, mcpClients, systemPrompt, maxSteps, thinkingLevel, and cleanup', async () => { + const result = await createAgentClient(baseConfig); + + expect(result.model).toBe(FAKE_MODEL); + expect(result.tools).toBeDefined(); + expect(result.mcpClients).toEqual([]); + expect(result.systemPrompt).toBe('You are a coder.'); + expect(result.maxSteps).toBe(200); + expect(result.thinkingLevel).toBeDefined(); + expect(typeof result.cleanup).toBe('function'); + }); + + it('uses agent-config default thinking level', async () => { + mockGetDefaultThinkingLevel.mockReturnValueOnce('high'); + + const result = await createAgentClient(baseConfig); + + expect(result.thinkingLevel).toBe('high'); + expect(mockGetDefaultThinkingLevel).toHaveBeenCalledWith('coder'); + }); + + it('overrides thinking level when thinkingLevel is specified', async () => { + const result = await createAgentClient({ ...baseConfig, thinkingLevel: 'low' }); + expect(result.thinkingLevel).toBe('low'); + }); + + it('uses specified maxSteps', async () => { + const result = await createAgentClient({ ...baseConfig, maxSteps: 50 }); + expect(result.maxSteps).toBe(50); + }); + + it('calls getToolsForAgent with agentType and toolContext', async () => { + const mockRegistry = { getToolsForAgent: vi.fn().mockReturnValue({ Read: {}, Write: {} }) }; + mockBuildToolRegistry.mockReturnValueOnce(mockRegistry as unknown as ToolRegistry); + + await createAgentClient(baseConfig); + + expect(mockRegistry.getToolsForAgent).toHaveBeenCalledWith('coder', baseToolContext); + }); + + it('creates MCP clients when agent requires servers', async () => { + const mockMcpClient = { serverId: 'context7', tools: { ctx7_tool: {} }, close: vi.fn() }; + mockGetRequiredMcpServers.mockReturnValueOnce(['context7']); + mockCreateMcpClientsForAgent.mockResolvedValueOnce([mockMcpClient] as unknown as McpClientResult[]); + mockMergeMcpTools.mockReturnValueOnce({ ctx7_tool: {} }); + + const result = await createAgentClient(baseConfig); + + expect(mockCreateMcpClientsForAgent).toHaveBeenCalledWith('coder', expect.any(Object)); + expect(result.mcpClients).toHaveLength(1); + expect(result.tools).toHaveProperty('ctx7_tool'); + }); + + it('cleanup calls closeAllMcpClients with the client list', async () => { + const result = await createAgentClient(baseConfig); + await result.cleanup(); + expect(mockCloseAllMcpClients).toHaveBeenCalledWith(result.mcpClients); + }); + + it('uses queue-based auth when queueConfig is provided', async () => { + const queueAuth = { + apiKey: 'sk-queue-coder', + source: 'profile-api-key' as const, + accountId: 'acc-coder', + resolvedProvider: 'anthropic' as const, + resolvedModelId: 'claude-sonnet-4-5-20250929', + reasoningConfig: { type: 'none' as const }, + }; + mockResolveAuthFromQueue.mockResolvedValueOnce(queueAuth); + + const result = await createAgentClient({ + ...baseConfig, + queueConfig: { + queue: [{ id: 'acc-coder' } as unknown as ProviderAccount], + requestedModel: 'claude-sonnet-4-5-20250929', + }, + }); + + expect(result.queueAuth).toBe(queueAuth); + expect(mockCreateProvider).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + provider: 'anthropic', + apiKey: 'sk-queue-coder', + }), + modelId: 'claude-sonnet-4-5-20250929', + }), + ); + }); + + it('throws when queueConfig provided but no account available', async () => { + mockResolveAuthFromQueue.mockResolvedValueOnce(null); + + await expect( + createAgentClient({ + ...baseConfig, + queueConfig: { queue: [], requestedModel: 'sonnet' }, + }), + ).rejects.toThrow('No available account in priority queue'); + }); + + it('merges additionalMcpServers into the required servers list', async () => { + mockGetRequiredMcpServers.mockReturnValueOnce(['context7']); + + await createAgentClient({ + ...baseConfig, + additionalMcpServers: ['custom-server'], + }); + + // createMcpClientsForAgent is called because the combined server list is non-empty + expect(mockCreateMcpClientsForAgent).toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/ai/mcp/__tests__/client.test.ts b/apps/desktop/src/main/ai/mcp/__tests__/client.test.ts new file mode 100644 index 00000000..151350fe --- /dev/null +++ b/apps/desktop/src/main/ai/mcp/__tests__/client.test.ts @@ -0,0 +1,316 @@ +/** + * Tests for MCP Client + * + * Validates transport creation, client initialization, parallel agent setup, + * tool merging, and cleanup behavior. + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +// Mock @ai-sdk/mcp using inline factory to avoid vi.mock hoisting issues +vi.mock('@ai-sdk/mcp', () => ({ + createMCPClient: vi.fn(), +})); + +// Mock StdioClientTransport constructor using a proper constructor function +vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({ + // biome-ignore lint/suspicious/noExplicitAny: test mock constructor + StdioClientTransport: vi.fn().mockImplementation(function (this: any) { + Object.assign(this, { __kind: 'stdio-transport' }); + }), +})); + +// Mock registry to control which servers get resolved +vi.mock('../registry', () => ({ + resolveMcpServers: vi.fn(), +})); + +// Mock agent-configs to control required servers +vi.mock('../../config/agent-configs', () => ({ + getRequiredMcpServers: vi.fn().mockReturnValue([]), +})); + +import { createMCPClient } from '@ai-sdk/mcp'; +import type { MCPClient } from '@ai-sdk/mcp'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { resolveMcpServers } from '../registry'; +import { getRequiredMcpServers } from '../../config/agent-configs'; +import type { McpServerResolveOptions } from '../../config/agent-configs'; +import { + createMcpClient, + createMcpClientsForAgent, + closeAllMcpClients, + mergeMcpTools, +} from '../client'; +import type { McpServerConfig } from '../types'; + +const mockCreateMCPClient = vi.mocked(createMCPClient); +const mockStdioClientTransport = vi.mocked(StdioClientTransport); +const mockResolveMcpServers = vi.mocked(resolveMcpServers); +const mockGetRequiredMcpServers = vi.mocked(getRequiredMcpServers); + +// Sentinel: what StdioClientTransport instances look like after construction +const FAKE_STDIO_TRANSPORT_PROPS = { __kind: 'stdio-transport' }; + +// Helper: build a mock MCP client instance +function makeMockMcpInstance(tools = { tool_a: {}, tool_b: {} }) { + return { + tools: vi.fn().mockResolvedValue(tools), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +// Helpers: server configs +const stdioConfig: McpServerConfig = { + id: 'test-stdio', + name: 'Test Stdio Server', + description: 'A test stdio server', + enabledByDefault: true, + transport: { + type: 'stdio', + command: 'npx', + args: ['-y', 'some-mcp-server'], + env: { MY_VAR: 'value' }, + }, +}; + +const httpConfig: McpServerConfig = { + id: 'test-http', + name: 'Test HTTP Server', + description: 'A test streamable-http server', + enabledByDefault: true, + transport: { + type: 'streamable-http', + url: 'https://mcp.example.com/sse', + headers: { Authorization: 'Bearer token123' }, + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + // Default: StdioClientTransport constructor sets __kind on instance + // biome-ignore lint/suspicious/noExplicitAny: test mock constructor + mockStdioClientTransport.mockImplementation(function (this: any) { + Object.assign(this, FAKE_STDIO_TRANSPORT_PROPS); + } as unknown as typeof StdioClientTransport); + // Default: createMCPClient returns a standard mock instance + mockCreateMCPClient.mockResolvedValue(makeMockMcpInstance() as unknown as MCPClient); + mockGetRequiredMcpServers.mockReturnValue([]); + mockResolveMcpServers.mockReturnValue([]); +}); + +// ============================================================================= +// createMcpClient — transport creation +// ============================================================================= + +describe('createMcpClient', () => { + it('creates a StdioClientTransport for stdio server config', async () => { + await createMcpClient(stdioConfig); + + expect(mockStdioClientTransport).toHaveBeenCalledWith({ + command: 'npx', + args: ['-y', 'some-mcp-server'], + env: expect.objectContaining({ MY_VAR: 'value' }), + cwd: undefined, + }); + // The transport passed to createMCPClient is an instance of the mocked StdioClientTransport + expect(mockCreateMCPClient).toHaveBeenCalledWith({ + transport: expect.objectContaining(FAKE_STDIO_TRANSPORT_PROPS), + }); + }); + + it('creates an SSE transport object for streamable-http config', async () => { + await createMcpClient(httpConfig); + + expect(mockCreateMCPClient).toHaveBeenCalledWith({ + transport: { + type: 'sse', + url: 'https://mcp.example.com/sse', + headers: { Authorization: 'Bearer token123' }, + }, + }); + // StdioClientTransport should NOT be called for HTTP config + expect(mockStdioClientTransport).not.toHaveBeenCalled(); + }); + + it('returns a result with serverId, tools, and close function', async () => { + const result = await createMcpClient(stdioConfig); + + expect(result.serverId).toBe('test-stdio'); + expect(result.tools).toEqual({ tool_a: {}, tool_b: {} }); + expect(typeof result.close).toBe('function'); + }); + + it('merges process.env with server env for stdio transport', async () => { + const originalPath = process.env.PATH; + process.env.PATH = '/usr/bin'; + + await createMcpClient(stdioConfig); + + expect(mockStdioClientTransport).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ PATH: '/usr/bin', MY_VAR: 'value' }), + }), + ); + + process.env.PATH = originalPath; + }); + + it('passes undefined env to StdioClientTransport when no env in config', async () => { + const noEnvConfig: McpServerConfig = { + ...stdioConfig, + transport: { type: 'stdio', command: 'node', args: ['server.js'] }, + }; + + await createMcpClient(noEnvConfig); + + expect(mockStdioClientTransport).toHaveBeenCalledWith( + expect.objectContaining({ env: undefined }), + ); + }); + + it('close() delegates to the underlying MCP client close method', async () => { + const mockInstance = makeMockMcpInstance(); + mockCreateMCPClient.mockResolvedValueOnce(mockInstance as unknown as MCPClient); + + const result = await createMcpClient(stdioConfig); + await result.close(); + + expect(mockInstance.close).toHaveBeenCalled(); + }); +}); + +// ============================================================================= +// createMcpClientsForAgent +// ============================================================================= + +describe('createMcpClientsForAgent', () => { + it('returns empty array when agent requires no MCP servers', async () => { + mockGetRequiredMcpServers.mockReturnValueOnce([]); + mockResolveMcpServers.mockReturnValueOnce([]); + + const clients = await createMcpClientsForAgent('commit_message'); + + expect(clients).toEqual([]); + }); + + it('creates clients for each resolved server config', async () => { + mockGetRequiredMcpServers.mockReturnValueOnce(['context7', 'auto-claude']); + mockResolveMcpServers.mockReturnValueOnce([ + { ...stdioConfig, id: 'context7' }, + { ...stdioConfig, id: 'auto-claude' }, + ]); + // Two separate mock instances for the two servers + mockCreateMCPClient + .mockResolvedValueOnce(makeMockMcpInstance() as unknown as MCPClient) + .mockResolvedValueOnce(makeMockMcpInstance() as unknown as MCPClient); + + const clients = await createMcpClientsForAgent('coder'); + + expect(clients).toHaveLength(2); + expect(clients[0].serverId).toBe('context7'); + expect(clients[1].serverId).toBe('auto-claude'); + }); + + it('skips failed connections without throwing', async () => { + mockGetRequiredMcpServers.mockReturnValueOnce(['context7', 'broken-server']); + mockResolveMcpServers.mockReturnValueOnce([ + { ...stdioConfig, id: 'context7' }, + { ...stdioConfig, id: 'broken-server' }, + ]); + + // First call succeeds, second call fails + mockCreateMCPClient + .mockResolvedValueOnce(makeMockMcpInstance() as unknown as MCPClient) + .mockRejectedValueOnce(new Error('connection refused')); + + const clients = await createMcpClientsForAgent('coder'); + + // Only the successful client should be returned + expect(clients).toHaveLength(1); + expect(clients[0].serverId).toBe('context7'); + }); + + it('passes resolveOptions to getRequiredMcpServers', async () => { + mockGetRequiredMcpServers.mockReturnValueOnce([]); + mockResolveMcpServers.mockReturnValueOnce([]); + + const resolveOptions = { electronMcpEnabled: true }; + await createMcpClientsForAgent('qa_reviewer', resolveOptions as unknown as McpServerResolveOptions); + + expect(mockGetRequiredMcpServers).toHaveBeenCalledWith('qa_reviewer', resolveOptions); + }); +}); + +// ============================================================================= +// mergeMcpTools +// ============================================================================= + +describe('mergeMcpTools', () => { + it('merges tools from multiple clients into a single object', () => { + const clients = [ + { serverId: 'a', tools: { tool1: {}, tool2: {} }, close: vi.fn() }, + { serverId: 'b', tools: { tool3: {}, tool4: {} }, close: vi.fn() }, + ]; + + const merged = mergeMcpTools(clients); + + expect(Object.keys(merged)).toHaveLength(4); + expect(merged).toHaveProperty('tool1'); + expect(merged).toHaveProperty('tool3'); + }); + + it('returns empty object for empty clients array', () => { + expect(mergeMcpTools([])).toEqual({}); + }); + + it('later client tools overwrite earlier ones on key collision', () => { + const clients = [ + { serverId: 'a', tools: { shared_tool: { version: 1 } }, close: vi.fn() }, + { serverId: 'b', tools: { shared_tool: { version: 2 } }, close: vi.fn() }, + ]; + + const merged = mergeMcpTools(clients); + + // biome-ignore lint/suspicious/noExplicitAny: test mock property access + expect((merged.shared_tool as any).version).toBe(2); + }); +}); + +// ============================================================================= +// closeAllMcpClients +// ============================================================================= + +describe('closeAllMcpClients', () => { + it('calls close on all clients', async () => { + const close1 = vi.fn().mockResolvedValue(undefined); + const close2 = vi.fn().mockResolvedValue(undefined); + const clients = [ + { serverId: 'a', tools: {}, close: close1 }, + { serverId: 'b', tools: {}, close: close2 }, + ]; + + await closeAllMcpClients(clients); + + expect(close1).toHaveBeenCalled(); + expect(close2).toHaveBeenCalled(); + }); + + it('resolves even when one client fails to close', async () => { + const close1 = vi.fn().mockResolvedValue(undefined); + const close2 = vi.fn().mockRejectedValue(new Error('close failed')); + const clients = [ + { serverId: 'a', tools: {}, close: close1 }, + { serverId: 'b', tools: {}, close: close2 }, + ]; + + // Should not throw + await expect(closeAllMcpClients(clients)).resolves.toBeUndefined(); + expect(close1).toHaveBeenCalled(); + expect(close2).toHaveBeenCalled(); + }); + + it('resolves immediately for empty clients array', async () => { + await expect(closeAllMcpClients([])).resolves.toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/main/ai/mcp/__tests__/registry.test.ts b/apps/desktop/src/main/ai/mcp/__tests__/registry.test.ts new file mode 100644 index 00000000..2d8c5f8d --- /dev/null +++ b/apps/desktop/src/main/ai/mcp/__tests__/registry.test.ts @@ -0,0 +1,185 @@ +/** + * Tests for MCP Server Registry + * + * Validates server configuration resolution, required server lookup, + * and option-based server filtering. + */ + +import { describe, expect, it } from 'vitest'; +import { getMcpServerConfig, resolveMcpServers } from '../registry'; + +// ============================================================================= +// getMcpServerConfig +// ============================================================================= + +describe('getMcpServerConfig', () => { + describe('context7', () => { + it('returns the context7 server config', () => { + const config = getMcpServerConfig('context7'); + expect(config).not.toBeNull(); + expect(config?.id).toBe('context7'); + expect(config?.enabledByDefault).toBe(true); + }); + + it('uses stdio transport with npx', () => { + const config = getMcpServerConfig('context7'); + expect(config?.transport.type).toBe('stdio'); + if (config?.transport.type === 'stdio') { + expect(config.transport.command).toBe('npx'); + } + }); + }); + + describe('linear', () => { + it('returns null when no API key provided', () => { + const config = getMcpServerConfig('linear', {}); + expect(config).toBeNull(); + }); + + it('returns config when linearApiKey is provided', () => { + const config = getMcpServerConfig('linear', { linearApiKey: 'lin_api_123' }); + expect(config).not.toBeNull(); + expect(config?.id).toBe('linear'); + }); + + it('returns config when LINEAR_API_KEY is in env option', () => { + const config = getMcpServerConfig('linear', { env: { LINEAR_API_KEY: 'lin_env_456' } }); + expect(config).not.toBeNull(); + }); + + it('injects LINEAR_API_KEY into the transport env', () => { + const config = getMcpServerConfig('linear', { linearApiKey: 'lin_inject' }); + expect(config?.transport.type).toBe('stdio'); + if (config?.transport.type === 'stdio') { + expect(config.transport.env?.LINEAR_API_KEY).toBe('lin_inject'); + } + }); + }); + + describe('memory', () => { + it('returns null when no memory URL provided', () => { + const config = getMcpServerConfig('memory', {}); + expect(config).toBeNull(); + }); + + it('returns config with streamable-http transport when URL is provided', () => { + const config = getMcpServerConfig('memory', { memoryMcpUrl: 'http://localhost:8080/mcp' }); + expect(config).not.toBeNull(); + expect(config?.transport.type).toBe('streamable-http'); + if (config?.transport.type === 'streamable-http') { + expect(config.transport.url).toBe('http://localhost:8080/mcp'); + } + }); + + it('reads URL from env.GRAPHITI_MCP_URL option', () => { + const config = getMcpServerConfig('memory', { env: { GRAPHITI_MCP_URL: 'http://graphiti.local' } }); + expect(config?.transport.type).toBe('streamable-http'); + }); + }); + + describe('electron', () => { + it('returns the electron server config', () => { + const config = getMcpServerConfig('electron'); + expect(config).not.toBeNull(); + expect(config?.id).toBe('electron'); + expect(config?.enabledByDefault).toBe(false); + }); + + it('uses stdio transport', () => { + const config = getMcpServerConfig('electron'); + expect(config?.transport.type).toBe('stdio'); + }); + }); + + describe('puppeteer', () => { + it('returns the puppeteer server config', () => { + const config = getMcpServerConfig('puppeteer'); + expect(config).not.toBeNull(); + expect(config?.id).toBe('puppeteer'); + }); + + it('uses stdio transport', () => { + const config = getMcpServerConfig('puppeteer'); + expect(config?.transport.type).toBe('stdio'); + }); + }); + + describe('auto-claude', () => { + it('returns auto-claude config with empty specDir as default', () => { + const config = getMcpServerConfig('auto-claude', {}); + expect(config).not.toBeNull(); + expect(config?.id).toBe('auto-claude'); + }); + + it('injects SPEC_DIR into transport env', () => { + const config = getMcpServerConfig('auto-claude', { specDir: '/project/.auto-claude/specs/001-feature' }); + expect(config?.transport.type).toBe('stdio'); + if (config?.transport.type === 'stdio') { + expect(config.transport.env?.SPEC_DIR).toBe('/project/.auto-claude/specs/001-feature'); + } + }); + + it('uses node command', () => { + const config = getMcpServerConfig('auto-claude', {}); + if (config?.transport.type === 'stdio') { + expect(config.transport.command).toBe('node'); + } + }); + }); + + describe('unknown server', () => { + it('returns null for unrecognized server ID', () => { + const config = getMcpServerConfig('nonexistent-server'); + expect(config).toBeNull(); + }); + }); +}); + +// ============================================================================= +// resolveMcpServers +// ============================================================================= + +describe('resolveMcpServers', () => { + it('returns configs for all recognized server IDs', () => { + const configs = resolveMcpServers(['context7', 'electron', 'puppeteer']); + expect(configs).toHaveLength(3); + expect(configs.map((c) => c.id)).toEqual(['context7', 'electron', 'puppeteer']); + }); + + it('filters out servers that cannot be configured (e.g. linear without API key)', () => { + const configs = resolveMcpServers(['context7', 'linear'], {}); + expect(configs).toHaveLength(1); + expect(configs[0].id).toBe('context7'); + }); + + it('includes linear when API key option is provided', () => { + const configs = resolveMcpServers(['context7', 'linear'], { linearApiKey: 'lin_test' }); + expect(configs).toHaveLength(2); + }); + + it('returns empty array for empty input', () => { + const configs = resolveMcpServers([]); + expect(configs).toEqual([]); + }); + + it('skips unrecognized server IDs silently', () => { + const configs = resolveMcpServers(['context7', 'bogus-server-id']); + expect(configs).toHaveLength(1); + expect(configs[0].id).toBe('context7'); + }); + + it('includes memory server when memoryMcpUrl is provided', () => { + const configs = resolveMcpServers(['memory'], { memoryMcpUrl: 'http://memory.local' }); + expect(configs).toHaveLength(1); + expect(configs[0].id).toBe('memory'); + }); + + it('passes specDir through to auto-claude config', () => { + const specDir = '/my-project/.auto-claude/specs/042-auth'; + const configs = resolveMcpServers(['auto-claude'], { specDir }); + expect(configs).toHaveLength(1); + if (configs[0].transport.type === 'stdio') { + expect(configs[0].transport.env?.SPEC_DIR).toBe(specDir); + } + }); +}); diff --git a/apps/desktop/src/main/ai/orchestration/__tests__/parallel-executor.test.ts b/apps/desktop/src/main/ai/orchestration/__tests__/parallel-executor.test.ts new file mode 100644 index 00000000..2ce75a02 --- /dev/null +++ b/apps/desktop/src/main/ai/orchestration/__tests__/parallel-executor.test.ts @@ -0,0 +1,334 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { executeParallel } from '../parallel-executor'; +import type { ParallelExecutorConfig, SubtaskSessionRunner } from '../parallel-executor'; +import type { SubtaskInfo } from '../build-orchestrator'; +import type { SessionResult } from '../../session/types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeSubtask(id: string): SubtaskInfo { + return { + id, + description: `Subtask ${id}`, + status: 'pending', + }; +} + +function makeResult(outcome: SessionResult['outcome']): SessionResult { + return { + outcome, + error: outcome === 'error' ? new Error('session error') : undefined, + totalSteps: 1, + lastMessage: '', + } as unknown as SessionResult; +} + +// --------------------------------------------------------------------------- +// Helper: run executeParallel with fake timers advanced automatically +// --------------------------------------------------------------------------- + +async function runWithFakeTimers(fn: () => Promise): Promise { + vi.useFakeTimers(); + try { + const promise = fn(); + await vi.runAllTimersAsync(); + return await promise; + } finally { + vi.useRealTimers(); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('executeParallel', () => { + // ------------------------------------------------------------------------- + // Empty task list + // ------------------------------------------------------------------------- + + it('returns empty results for an empty subtask list', async () => { + const runner = vi.fn() as unknown as SubtaskSessionRunner; + const result = await executeParallel([], runner); + + expect(result.results).toHaveLength(0); + expect(result.successCount).toBe(0); + expect(result.failureCount).toBe(0); + expect(result.rateLimitedCount).toBe(0); + expect(result.cancelled).toBe(false); + expect(runner).not.toHaveBeenCalled(); + }); + + // ------------------------------------------------------------------------- + // All succeed + // ------------------------------------------------------------------------- + + it('returns successCount equal to number of subtasks when all succeed', async () => { + const subtasks = [makeSubtask('t1'), makeSubtask('t2'), makeSubtask('t3')]; + const runner = vi.fn().mockResolvedValue(makeResult('completed')) as SubtaskSessionRunner; + + const result = await runWithFakeTimers(() => + executeParallel(subtasks, runner, { maxConcurrency: 10 }), + ); + + expect(result.successCount).toBe(3); + expect(result.failureCount).toBe(0); + expect(result.rateLimitedCount).toBe(0); + expect(result.cancelled).toBe(false); + expect(result.results).toHaveLength(3); + for (const r of result.results) { + expect(r.success).toBe(true); + expect(r.rateLimited).toBe(false); + } + }); + + it('maps subtaskIds correctly in results', async () => { + const subtasks = [makeSubtask('alpha'), makeSubtask('beta')]; + const runner = vi.fn().mockResolvedValue(makeResult('completed')) as SubtaskSessionRunner; + + const result = await runWithFakeTimers(() => + executeParallel(subtasks, runner, { maxConcurrency: 10 }), + ); + const ids = result.results.map((r) => r.subtaskId); + + expect(ids).toContain('alpha'); + expect(ids).toContain('beta'); + }); + + // ------------------------------------------------------------------------- + // Partial failure + // ------------------------------------------------------------------------- + + it('handles partial failure — some succeed, some fail', async () => { + const subtasks = [makeSubtask('s1'), makeSubtask('s2'), makeSubtask('s3')]; + + const runner = vi.fn() + .mockResolvedValueOnce(makeResult('completed')) + .mockResolvedValueOnce(makeResult('error')) + .mockResolvedValueOnce(makeResult('completed')) as SubtaskSessionRunner; + + const result = await runWithFakeTimers(() => + executeParallel(subtasks, runner, { maxConcurrency: 10 }), + ); + + expect(result.successCount).toBe(2); + expect(result.failureCount).toBe(1); + expect(result.rateLimitedCount).toBe(0); + }); + + // ------------------------------------------------------------------------- + // All fail + // ------------------------------------------------------------------------- + + it('handles all-fail scenario gracefully', async () => { + const subtasks = [makeSubtask('f1'), makeSubtask('f2')]; + const runner = vi.fn().mockResolvedValue(makeResult('error')) as SubtaskSessionRunner; + + const result = await runWithFakeTimers(() => + executeParallel(subtasks, runner, { maxConcurrency: 10 }), + ); + + expect(result.successCount).toBe(0); + expect(result.failureCount).toBe(2); + expect(result.cancelled).toBe(false); + }); + + // ------------------------------------------------------------------------- + // Rate limiting + // ------------------------------------------------------------------------- + + it('tracks rate-limited subtasks separately', async () => { + const subtasks = [makeSubtask('r1'), makeSubtask('r2')]; + + const runner = vi.fn() + .mockResolvedValueOnce(makeResult('rate_limited')) + .mockResolvedValueOnce(makeResult('completed')) as SubtaskSessionRunner; + + const result = await runWithFakeTimers(() => + executeParallel(subtasks, runner, { maxConcurrency: 10 }), + ); + + expect(result.rateLimitedCount).toBe(1); + expect(result.successCount).toBe(1); + }); + + it('calls onRateLimited callback when rate-limited result is detected in first batch', async () => { + // Single-item batches (maxConcurrency=1) so back-off delay fires between batches + const subtasks = [makeSubtask('rl1'), makeSubtask('rl2')]; + + const runner = vi.fn() + .mockResolvedValueOnce(makeResult('rate_limited')) + .mockResolvedValueOnce(makeResult('completed')) as SubtaskSessionRunner; + + const onRateLimited = vi.fn(); + const config: ParallelExecutorConfig = { maxConcurrency: 1, onRateLimited }; + + await runWithFakeTimers(() => executeParallel(subtasks, runner, config)); + + expect(onRateLimited).toHaveBeenCalledWith(expect.any(Number)); + }); + + // ------------------------------------------------------------------------- + // Concurrency limit batching + // ------------------------------------------------------------------------- + + it('respects maxConcurrency and processes all tasks in batches', async () => { + const subtasks = [ + makeSubtask('b1'), makeSubtask('b2'), makeSubtask('b3'), + makeSubtask('b4'), makeSubtask('b5'), + ]; + const runner = vi.fn().mockResolvedValue(makeResult('completed')) as SubtaskSessionRunner; + + const result = await runWithFakeTimers(() => + executeParallel(subtasks, runner, { maxConcurrency: 3 }), + ); + + expect(result.successCount).toBe(5); + expect(result.results).toHaveLength(5); + expect(runner).toHaveBeenCalledTimes(5); + }); + + // ------------------------------------------------------------------------- + // Callbacks — onSubtaskStart / onSubtaskComplete / onSubtaskFailed + // ------------------------------------------------------------------------- + + it('calls onSubtaskStart for each subtask', async () => { + const subtasks = [makeSubtask('c1'), makeSubtask('c2')]; + const runner = vi.fn().mockResolvedValue(makeResult('completed')) as SubtaskSessionRunner; + const onSubtaskStart = vi.fn(); + + await runWithFakeTimers(() => + executeParallel(subtasks, runner, { maxConcurrency: 10, onSubtaskStart }), + ); + + expect(onSubtaskStart).toHaveBeenCalledTimes(2); + expect(onSubtaskStart).toHaveBeenCalledWith(expect.objectContaining({ id: 'c1' })); + expect(onSubtaskStart).toHaveBeenCalledWith(expect.objectContaining({ id: 'c2' })); + }); + + it('calls onSubtaskComplete for successful subtasks — single task (no stagger)', async () => { + const subtasks = [makeSubtask('ok1')]; + const runner = vi.fn().mockResolvedValue(makeResult('completed')) as SubtaskSessionRunner; + const onSubtaskComplete = vi.fn(); + + // Single item at index 0 → stagger = 0ms → no fake timers needed + const result = await executeParallel(subtasks, runner, { maxConcurrency: 1, onSubtaskComplete }); + + expect(onSubtaskComplete).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ok1' }), + expect.objectContaining({ outcome: 'completed' }), + ); + expect(result.successCount).toBe(1); + }); + + it('calls onSubtaskFailed for error outcomes — single task', async () => { + const subtasks = [makeSubtask('fail1')]; + const runner = vi.fn().mockResolvedValue(makeResult('error')) as SubtaskSessionRunner; + const onSubtaskFailed = vi.fn(); + + const result = await executeParallel(subtasks, runner, { maxConcurrency: 1, onSubtaskFailed }); + + expect(onSubtaskFailed).toHaveBeenCalledWith( + expect.objectContaining({ id: 'fail1' }), + expect.any(Error), + ); + expect(result.failureCount).toBe(1); + }); + + it('calls onSubtaskFailed when runner throws — single task', async () => { + const subtasks = [makeSubtask('throw1')]; + const runner = vi.fn().mockRejectedValue(new Error('Unexpected crash')) as SubtaskSessionRunner; + const onSubtaskFailed = vi.fn(); + + const result = await executeParallel(subtasks, runner, { maxConcurrency: 1, onSubtaskFailed }); + + expect(result.failureCount).toBe(1); + expect(onSubtaskFailed).toHaveBeenCalledWith( + expect.objectContaining({ id: 'throw1' }), + expect.any(Error), + ); + }); + + // ------------------------------------------------------------------------- + // Cancellation via AbortSignal + // ------------------------------------------------------------------------- + + it('marks cancelled=true when aborted before execution starts', async () => { + const controller = new AbortController(); + controller.abort(); + + const subtasks = [makeSubtask('x1'), makeSubtask('x2')]; + const runner = vi.fn().mockResolvedValue(makeResult('completed')) as SubtaskSessionRunner; + + const result = await runWithFakeTimers(() => + executeParallel(subtasks, runner, { + maxConcurrency: 10, + abortSignal: controller.signal, + }), + ); + + expect(result.cancelled).toBe(true); + }); + + it('returns cancelled=true when aborted after first batch completes', async () => { + const controller = new AbortController(); + const subtasks = [makeSubtask('a1'), makeSubtask('a2')]; + + const runner = vi.fn().mockImplementation(async (subtask: SubtaskInfo) => { + if (subtask.id === 'a1') { + controller.abort(); + } + return makeResult('completed'); + }) as SubtaskSessionRunner; + + const result = await runWithFakeTimers(() => + executeParallel(subtasks, runner, { + maxConcurrency: 1, + abortSignal: controller.signal, + }), + ); + + expect(result.cancelled).toBe(true); + }); + + // ------------------------------------------------------------------------- + // Rate-limited error from thrown exception — single task, no stagger + // ------------------------------------------------------------------------- + + it('marks rateLimited=true when thrown error contains 429', async () => { + const subtasks = [makeSubtask('rl-throw')]; + const runner = vi.fn().mockRejectedValue(new Error('HTTP 429 too many requests')) as SubtaskSessionRunner; + + const result = await executeParallel(subtasks, runner, { maxConcurrency: 1 }); + + expect(result.results[0].rateLimited).toBe(true); + }); + + // ------------------------------------------------------------------------- + // Result structure — single task, no stagger + // ------------------------------------------------------------------------- + + it('includes session result in ParallelSubtaskResult when session ran', async () => { + const subtasks = [makeSubtask('struct1')]; + const sessionResult = makeResult('completed'); + const runner = vi.fn().mockResolvedValue(sessionResult) as SubtaskSessionRunner; + + const result = await executeParallel(subtasks, runner); + + expect(result.results[0].result).toBeDefined(); + expect(result.results[0].result?.outcome).toBe('completed'); + }); + + it('includes error string when runner throws', async () => { + const subtasks = [makeSubtask('err-str')]; + const runner = vi.fn().mockRejectedValue(new Error('crash detail')) as SubtaskSessionRunner; + + const result = await executeParallel(subtasks, runner); + + expect(result.results[0].error).toContain('crash detail'); + expect(result.results[0].success).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/ai/orchestration/__tests__/qa-loop.test.ts b/apps/desktop/src/main/ai/orchestration/__tests__/qa-loop.test.ts new file mode 100644 index 00000000..c30ff044 --- /dev/null +++ b/apps/desktop/src/main/ai/orchestration/__tests__/qa-loop.test.ts @@ -0,0 +1,451 @@ +import path from 'node:path'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockReadFile = vi.fn(); +const mockWriteFile = vi.fn(); +const mockUnlink = vi.fn(); + +vi.mock('node:fs/promises', () => ({ + readFile: (...args: unknown[]) => mockReadFile(...args), + writeFile: (...args: unknown[]) => mockWriteFile(...args), + unlink: (...args: unknown[]) => mockUnlink(...args), +})); + +vi.mock('../../utils/json-repair', () => ({ + safeParseJson: (raw: string) => { + try { + return JSON.parse(raw); + } catch { + return null; + } + }, +})); + +vi.mock('../qa-reports', () => ({ + generateQAReport: vi.fn(() => '# QA Report'), + generateEscalationReport: vi.fn(() => '# Escalation Report'), + generateManualTestPlan: vi.fn().mockResolvedValue('# Manual Test Plan'), +})); + +// qa-loop.ts imports from '../schema' (relative to orchestration/) +// which resolves to src/main/ai/schema/index.ts +vi.mock('../../schema', () => ({ + QASignoffSchema: {}, + validateStructuredOutput: vi.fn((_data: unknown, _schema: unknown) => ({ + valid: true, + data: _data, + })), +})); + +import { QALoop } from '../qa-loop'; +import type { QALoopConfig, QASessionRunConfig } from '../qa-loop'; +import type { SessionResult } from '../../session/types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const SPEC_DIR = '/project/.auto-claude/specs/001-feature'; +const PROJECT_DIR = '/project'; + +function completedPlan(qaStatus?: 'approved' | 'rejected' | 'unknown') { + const plan: Record = { + phases: [ + { subtasks: [{ status: 'completed' }, { status: 'completed' }] }, + ], + }; + + if (qaStatus === 'approved') { + plan.qa_signoff = { status: 'approved', issues_found: [] }; + } else if (qaStatus === 'rejected') { + plan.qa_signoff = { status: 'rejected', issues_found: [{ title: 'Test failure', type: 'critical' }] }; + } + // qaStatus === 'unknown' → no qa_signoff key + + return JSON.stringify(plan); +} + +function makeSessionResult(outcome: SessionResult['outcome']): SessionResult { + return { + outcome, + error: outcome === 'error' ? new Error('session error') : undefined, + totalSteps: 1, + lastMessage: '', + } as unknown as SessionResult; +} + +function makeConfig(overrides: Partial = {}): QALoopConfig { + return { + specDir: SPEC_DIR, + projectDir: PROJECT_DIR, + maxIterations: 5, + generatePrompt: vi.fn().mockResolvedValue('system prompt'), + runSession: vi.fn().mockResolvedValue(makeSessionResult('completed')), + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('QALoop', () => { + beforeEach(() => { + mockReadFile.mockReset(); + mockWriteFile.mockReset().mockResolvedValue(undefined); + mockUnlink.mockReset().mockResolvedValue(undefined); + }); + + // ------------------------------------------------------------------------- + // Build completeness guard + // ------------------------------------------------------------------------- + + it('returns error outcome when build is not complete', async () => { + // Plan with a non-completed subtask + const plan = JSON.stringify({ + phases: [{ subtasks: [{ status: 'pending' }] }], + }); + + // No QA_FIX_REQUEST.md either + mockReadFile.mockImplementation((path: string) => { + if (path.endsWith('implementation_plan.json')) return Promise.resolve(plan); + return Promise.reject(new Error('ENOENT')); + }); + + const config = makeConfig(); + const loop = new QALoop(config); + const outcome = await loop.run(); + + expect(outcome.approved).toBe(false); + expect(outcome.reason).toBe('error'); + }); + + // ------------------------------------------------------------------------- + // Already approved + // ------------------------------------------------------------------------- + + it('returns approved immediately when QA signoff is already "approved"', async () => { + const plan = completedPlan('approved'); + + mockReadFile.mockImplementation((path: string) => { + if (path.endsWith('implementation_plan.json')) return Promise.resolve(plan); + // QA_FIX_REQUEST.md does not exist + return Promise.reject(new Error('ENOENT')); + }); + + const config = makeConfig(); + const loop = new QALoop(config); + const outcome = await loop.run(); + + expect(outcome.approved).toBe(true); + expect(outcome.totalIterations).toBe(0); + // runSession should NOT have been called (short-circuit) + expect(config.runSession).not.toHaveBeenCalled(); + }); + + // ------------------------------------------------------------------------- + // QA approved on first iteration + // ------------------------------------------------------------------------- + + it('approves on the first iteration when reviewer returns approved', async () => { + // Let the reviewer run session set the approved state, then all subsequent reads return approved + let sessionCallCount = 0; + let _planReadCount = 0; + + const runSession = vi.fn().mockImplementation(async () => { + sessionCallCount++; + return makeSessionResult('completed'); + }); + + mockReadFile.mockImplementation((path: string) => { + if (path.endsWith('implementation_plan.json')) { + _planReadCount++; + // Before the reviewer has run, return no signoff (build complete, no qa yet) + if (sessionCallCount === 0) return Promise.resolve(completedPlan()); + // After the reviewer ran, return approved + return Promise.resolve(completedPlan('approved')); + } + return Promise.reject(new Error('ENOENT')); + }); + + const config = makeConfig({ runSession, maxIterations: 5 }); + const loop = new QALoop(config); + const outcome = await loop.run(); + + expect(outcome.approved).toBe(true); + // Should have approved within the first few iterations + expect(outcome.totalIterations).toBeGreaterThanOrEqual(1); + // Only the reviewer should have been called (no fixer needed) + const calls = runSession.mock.calls as Array<[QASessionRunConfig]>; + expect(calls.every((c) => c[0].agentType === 'qa_reviewer')).toBe(true); + }); + + // ------------------------------------------------------------------------- + // Rejected then approved on retry + // ------------------------------------------------------------------------- + + it('runs fixer then approves on second iteration', async () => { + // Track how many times runSession has been called so we know which "phase" we're in + let sessionCallCount = 0; + let planReadCount = 0; + + const runSession = vi.fn().mockImplementation(async () => { + sessionCallCount++; + return makeSessionResult('completed'); + }); + + mockReadFile.mockImplementation((path: string) => { + if (path.endsWith('implementation_plan.json')) { + planReadCount++; + if (planReadCount === 1) return Promise.resolve(completedPlan()); // isBuildComplete + // Reviewer on iteration 1 ran when sessionCallCount >= 1 + // Serve rejected until fixer has run (sessionCallCount >= 2), then approved + if (sessionCallCount < 2) { + return Promise.resolve(completedPlan('rejected')); + } + return Promise.resolve(completedPlan('approved')); + } + return Promise.reject(new Error('ENOENT')); + }); + + const config = makeConfig({ runSession, maxIterations: 5 }); + const loop = new QALoop(config); + const outcome = await loop.run(); + + expect(outcome.approved).toBe(true); + // At minimum: reviewer (iter 1) + fixer + reviewer (iter 2) = 3 + expect(sessionCallCount).toBeGreaterThanOrEqual(3); + const calls = runSession.mock.calls as Array<[QASessionRunConfig]>; + const agentTypes = calls.map((c) => c[0].agentType); + expect(agentTypes).toContain('qa_reviewer'); + expect(agentTypes).toContain('qa_fixer'); + }); + + // ------------------------------------------------------------------------- + // Max iterations reached + // ------------------------------------------------------------------------- + + it('returns max_iterations when approval is never reached', async () => { + // Always return "rejected" status with a unique issue each time + // so recurring_issues threshold is never reached within maxIterations=2 + let planReadCount = 0; + + mockReadFile.mockImplementation((path: string) => { + if (path.endsWith('implementation_plan.json')) { + planReadCount++; + if (planReadCount === 1) return Promise.resolve(completedPlan()); // build complete check + + // Return distinct issues each time to avoid recurring_issues escalation + const plan = JSON.stringify({ + phases: [{ subtasks: [{ status: 'completed' }] }], + qa_signoff: { + status: 'rejected', + issues_found: [{ title: `Unique issue ${planReadCount}`, type: 'warning' }], + }, + }); + return Promise.resolve(plan); + } + return Promise.reject(new Error('ENOENT')); + }); + + const config = makeConfig({ maxIterations: 2 }); + const loop = new QALoop(config); + const outcome = await loop.run(); + + expect(outcome.approved).toBe(false); + expect(outcome.reason).toBe('max_iterations'); + }); + + // ------------------------------------------------------------------------- + // Consecutive error escalation + // ------------------------------------------------------------------------- + + it('escalates after MAX_CONSECUTIVE_ERRORS (3) consecutive unknown status responses', async () => { + let planReadCount = 0; + + mockReadFile.mockImplementation((path: string) => { + if (path.endsWith('implementation_plan.json')) { + planReadCount++; + if (planReadCount === 1) return Promise.resolve(completedPlan()); // build complete + // Return a plan with no qa_signoff — "unknown" status + const planWithNoSignoff = JSON.stringify({ + phases: [{ subtasks: [{ status: 'completed' }] }], + }); + return Promise.resolve(planWithNoSignoff); + } + return Promise.reject(new Error('ENOENT')); + }); + + const config = makeConfig({ maxIterations: 10 }); + const loop = new QALoop(config); + const outcome = await loop.run(); + + expect(outcome.approved).toBe(false); + expect(outcome.reason).toBe('consecutive_errors'); + }); + + // ------------------------------------------------------------------------- + // Recurring issue detection + // ------------------------------------------------------------------------- + + it('escalates when the same issue recurs 3 or more times', async () => { + const recurringIssue = { title: 'Null pointer exception', type: 'critical' as const }; + const rejectedPlan = JSON.stringify({ + phases: [{ subtasks: [{ status: 'completed' }] }], + qa_signoff: { status: 'rejected', issues_found: [recurringIssue] }, + }); + + let planReadCount = 0; + + mockReadFile.mockImplementation((path: string) => { + if (path.endsWith('implementation_plan.json')) { + planReadCount++; + if (planReadCount === 1) return Promise.resolve(completedPlan()); // build complete + return Promise.resolve(rejectedPlan); + } + return Promise.reject(new Error('ENOENT')); + }); + + const config = makeConfig({ maxIterations: 10 }); + const loop = new QALoop(config); + const outcome = await loop.run(); + + expect(outcome.approved).toBe(false); + expect(outcome.reason).toBe('recurring_issues'); + }); + + // ------------------------------------------------------------------------- + // Cancellation via AbortSignal + // ------------------------------------------------------------------------- + + it('returns cancelled outcome when aborted before first iteration runs', async () => { + const controller = new AbortController(); + + mockReadFile.mockImplementation((path: string) => { + if (path.endsWith('implementation_plan.json')) return Promise.resolve(completedPlan()); + return Promise.reject(new Error('ENOENT')); + }); + + const config = makeConfig({ abortSignal: controller.signal, maxIterations: 5 }); + const loop = new QALoop(config); + + // Abort after construction so the event listener fires + controller.abort(); + + const outcome = await loop.run(); + + expect(outcome.approved).toBe(false); + expect(outcome.reason).toBe('cancelled'); + }); + + // ------------------------------------------------------------------------- + // Fixer error handling + // ------------------------------------------------------------------------- + + it('returns error outcome when fixer session fails', async () => { + let planReadCount = 0; + + mockReadFile.mockImplementation((path: string) => { + if (path.endsWith('implementation_plan.json')) { + planReadCount++; + if (planReadCount === 1) return Promise.resolve(completedPlan()); + return Promise.resolve(completedPlan('rejected')); + } + return Promise.reject(new Error('ENOENT')); + }); + + const runSession = vi.fn() + .mockResolvedValueOnce(makeSessionResult('completed')) // reviewer iteration 1 + .mockResolvedValueOnce(makeSessionResult('error')); // fixer fails + + const config = makeConfig({ runSession, maxIterations: 5 }); + const loop = new QALoop(config); + const outcome = await loop.run(); + + expect(outcome.approved).toBe(false); + expect(outcome.reason).toBe('error'); + }); + + // ------------------------------------------------------------------------- + // Reviewer cancelled mid-loop + // ------------------------------------------------------------------------- + + it('returns cancelled when reviewer session is cancelled', async () => { + let planReadCount = 0; + + mockReadFile.mockImplementation((path: string) => { + if (path.endsWith('implementation_plan.json')) { + planReadCount++; + if (planReadCount === 1) return Promise.resolve(completedPlan()); + return Promise.resolve(completedPlan()); + } + return Promise.reject(new Error('ENOENT')); + }); + + const runSession = vi.fn().mockResolvedValueOnce(makeSessionResult('cancelled')); + + const config = makeConfig({ runSession, maxIterations: 5 }); + const loop = new QALoop(config); + const outcome = await loop.run(); + + expect(outcome.approved).toBe(false); + expect(outcome.reason).toBe('cancelled'); + }); + + // ------------------------------------------------------------------------- + // Human feedback processing + // ------------------------------------------------------------------------- + + it('processes QA_FIX_REQUEST.md before running the review loop', async () => { + // QA_FIX_REQUEST.md exists + mockReadFile.mockImplementation((path: string) => { + if (path.endsWith('QA_FIX_REQUEST.md')) return Promise.resolve('Fix this please'); + if (path.endsWith('implementation_plan.json')) return Promise.resolve(completedPlan('approved')); + return Promise.reject(new Error('ENOENT')); + }); + + const runSession = vi.fn().mockResolvedValue(makeSessionResult('completed')); + const config = makeConfig({ runSession, maxIterations: 5 }); + const loop = new QALoop(config); + const outcome = await loop.run(); + + // Fixer should have been invoked for human feedback + const calls = runSession.mock.calls as Array<[QASessionRunConfig]>; + expect(calls.some((c) => c[0].agentType === 'qa_fixer')).toBe(true); + // Fix request file should be deleted + expect(mockUnlink).toHaveBeenCalledWith(path.join(SPEC_DIR, 'QA_FIX_REQUEST.md')); + // Overall outcome should still reflect the QA result + expect(outcome.approved).toBe(true); + }); + + // ------------------------------------------------------------------------- + // Events + // ------------------------------------------------------------------------- + + it('emits qa-complete event with the final outcome', async () => { + let planReadCount = 0; + mockReadFile.mockImplementation((path: string) => { + if (path.endsWith('implementation_plan.json')) { + planReadCount++; + if (planReadCount === 1) return Promise.resolve(completedPlan()); + return Promise.resolve(completedPlan('approved')); + } + return Promise.reject(new Error('ENOENT')); + }); + + const config = makeConfig(); + const loop = new QALoop(config); + + const completedEvents: unknown[] = []; + loop.on('qa-complete', (outcome) => completedEvents.push(outcome)); + + await loop.run(); + + expect(completedEvents).toHaveLength(1); + expect((completedEvents[0] as { approved: boolean }).approved).toBe(true); + }); +}); diff --git a/apps/desktop/src/main/ai/orchestration/__tests__/qa-reports.test.ts b/apps/desktop/src/main/ai/orchestration/__tests__/qa-reports.test.ts new file mode 100644 index 00000000..078b8f21 --- /dev/null +++ b/apps/desktop/src/main/ai/orchestration/__tests__/qa-reports.test.ts @@ -0,0 +1,364 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockReadFile = vi.fn(); +const mockExistsSync = vi.fn(); +const mockReaddirSync = vi.fn(); + +vi.mock('node:fs/promises', () => ({ + readFile: (...args: unknown[]) => mockReadFile(...args), +})); + +vi.mock('node:fs', () => ({ + existsSync: (...args: unknown[]) => mockExistsSync(...args), + readdirSync: (...args: unknown[]) => mockReaddirSync(...args), +})); + +import { + generateQAReport, + generateEscalationReport, + generateManualTestPlan, + issuesSimilar, + isNoTestProject, +} from '../qa-reports'; +import type { QAIterationRecord, QAIssue } from '../qa-loop'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeRecord( + iteration: number, + status: 'approved' | 'rejected' | 'error', + issues: QAIssue[] = [], + durationMs = 1000, +): QAIterationRecord { + return { + iteration, + status, + issues, + durationMs, + timestamp: new Date().toISOString(), + }; +} + +function makeIssue(title: string, opts: Partial = {}): QAIssue { + return { title, ...opts }; +} + +// --------------------------------------------------------------------------- +// generateQAReport +// --------------------------------------------------------------------------- + +describe('generateQAReport', () => { + it('produces a report with APPROVED status label', () => { + const iterations: QAIterationRecord[] = [ + makeRecord(1, 'rejected', [makeIssue('Missing test')], 2000), + makeRecord(2, 'approved', [], 1500), + ]; + + const report = generateQAReport(iterations, 'approved'); + + expect(report).toContain('APPROVED'); + expect(report).toContain('PASSED'); + expect(report).toContain('Total Iterations'); + expect(report).toContain('2'); + }); + + it('produces a report with ESCALATED status label', () => { + const iterations: QAIterationRecord[] = [ + makeRecord(1, 'rejected', [makeIssue('Null pointer')], 500), + ]; + + const report = generateQAReport(iterations, 'escalated'); + + expect(report).toContain('ESCALATED'); + expect(report).toContain('FAILED'); + expect(report).toContain('escalated to human review'); + }); + + it('produces a report with MAX ITERATIONS REACHED label', () => { + const iterations: QAIterationRecord[] = [ + makeRecord(1, 'rejected', [], 800), + makeRecord(2, 'rejected', [], 800), + ]; + + const report = generateQAReport(iterations, 'max_iterations'); + + expect(report).toContain('MAX ITERATIONS REACHED'); + expect(report).toContain('FAILED'); + expect(report).toContain('maximum'); + }); + + it('handles empty iteration history gracefully', () => { + const report = generateQAReport([], 'approved'); + + expect(report).toContain('No iterations recorded'); + expect(report).toContain('Total Iterations'); + }); + + it('includes issue details in iteration history section', () => { + const issue = makeIssue('Type error in auth.ts', { + type: 'critical', + location: 'src/auth.ts:42', + description: 'Property does not exist', + fix_required: 'Add null check', + }); + + const report = generateQAReport([makeRecord(1, 'rejected', [issue])], 'escalated'); + + expect(report).toContain('Type error in auth.ts'); + expect(report).toContain('[CRITICAL]'); + expect(report).toContain('src/auth.ts:42'); + expect(report).toContain('Property does not exist'); + expect(report).toContain('Add null check'); + }); + + it('calculates summary counts correctly', () => { + const iterations: QAIterationRecord[] = [ + makeRecord(1, 'rejected', [makeIssue('A'), makeIssue('B')]), + makeRecord(2, 'error', [makeIssue('C')]), + makeRecord(3, 'approved', []), + ]; + + const report = generateQAReport(iterations, 'approved'); + + expect(report).toContain('Approved Iterations'); + expect(report).toContain('Rejected Iterations'); + expect(report).toContain('Error Iterations'); + }); +}); + +// --------------------------------------------------------------------------- +// generateEscalationReport +// --------------------------------------------------------------------------- + +describe('generateEscalationReport', () => { + it('lists recurring issues by title', () => { + const recurringIssues: QAIssue[] = [ + makeIssue('Database connection leak', { + type: 'critical', + location: 'src/db.ts', + description: 'Connection is never closed', + fix_required: 'Use try-finally block', + }), + ]; + + const iterations: QAIterationRecord[] = [ + makeRecord(1, 'rejected', recurringIssues), + makeRecord(2, 'rejected', recurringIssues), + makeRecord(3, 'rejected', recurringIssues), + ]; + + const report = generateEscalationReport(iterations, recurringIssues); + + expect(report).toContain('Human Intervention Required'); + expect(report).toContain('Database connection leak'); + expect(report).toContain('src/db.ts'); + expect(report).toContain('Connection is never closed'); + expect(report).toContain('Use try-finally block'); + }); + + it('includes summary statistics', () => { + const issue = makeIssue('Error X'); + const iterations = [ + makeRecord(1, 'rejected', [issue]), + makeRecord(2, 'rejected', [issue]), + makeRecord(3, 'rejected', [issue]), + ]; + + const report = generateEscalationReport(iterations, [issue]); + + expect(report).toContain('Total QA Iterations'); + expect(report).toContain('Total Issues Found'); + expect(report).toContain('Unique Issues'); + expect(report).toContain('Fix Success Rate'); + }); + + it('includes recommended actions section', () => { + const report = generateEscalationReport([], []); + + expect(report).toContain('Recommended Actions'); + expect(report).toContain('QA_FIX_REQUEST.md'); + }); + + it('includes most common issues when present', () => { + const issue1 = makeIssue('Common bug'); + const issue2 = makeIssue('Rare bug'); + + const iterations = [ + makeRecord(1, 'rejected', [issue1, issue2]), + makeRecord(2, 'rejected', [issue1]), + makeRecord(3, 'rejected', [issue1]), + ]; + + const report = generateEscalationReport(iterations, [issue1]); + + expect(report).toContain('Most Common Issues'); + expect(report).toContain('common bug'); + }); +}); + +// --------------------------------------------------------------------------- +// generateManualTestPlan +// --------------------------------------------------------------------------- + +describe('generateManualTestPlan', () => { + const SPEC_DIR = '/project/.auto-claude/specs/001-feature'; + const PROJECT_DIR = '/project'; + + beforeEach(() => { + mockReadFile.mockReset(); + mockExistsSync.mockReset().mockReturnValue(false); + mockReaddirSync.mockReset().mockReturnValue([]); + }); + + it('generates a basic test plan when spec.md is missing', async () => { + mockReadFile.mockRejectedValue(new Error('ENOENT')); + + const plan = await generateManualTestPlan(SPEC_DIR, PROJECT_DIR); + + expect(plan).toContain('Manual Test Plan'); + expect(plan).toContain('Pre-Test Setup'); + expect(plan).toContain('Functional Tests'); + expect(plan).toContain('Sign-off'); + }); + + it('extracts acceptance criteria from spec.md when available', async () => { + const specContent = `# Feature Spec + +## Overview +Some description. + +## Acceptance Criteria +- User can log in +- User sees dashboard after login +- Invalid credentials show error + +## Technical Details +Not relevant here. +`; + + mockReadFile.mockResolvedValue(specContent); + + const plan = await generateManualTestPlan(SPEC_DIR, PROJECT_DIR); + + expect(plan).toContain('User can log in'); + expect(plan).toContain('User sees dashboard after login'); + expect(plan).toContain('Invalid credentials show error'); + }); + + it('notes "no automated test framework" when none is detected', async () => { + mockReadFile.mockRejectedValue(new Error('ENOENT')); + // existsSync returns false → no test config found + + const plan = await generateManualTestPlan(SPEC_DIR, PROJECT_DIR); + + expect(plan).toContain('No automated test framework detected'); + }); + + it('notes "supplemental manual verification" when a test framework is present', async () => { + mockReadFile.mockRejectedValue(new Error('ENOENT')); + // Simulate vitest.config.ts existing + mockExistsSync.mockImplementation((p: string) => p.endsWith('vitest.config.ts')); + + const plan = await generateManualTestPlan(SPEC_DIR, PROJECT_DIR); + + expect(plan).toContain('supplement to automated tests'); + }); +}); + +// --------------------------------------------------------------------------- +// issuesSimilar +// --------------------------------------------------------------------------- + +describe('issuesSimilar', () => { + it('returns true for identical issues', () => { + const issue = makeIssue('Null pointer exception', { description: 'Null reference in auth module' }); + expect(issuesSimilar(issue, issue)).toBe(true); + }); + + it('returns true for issues with high token overlap', () => { + const a = makeIssue('null pointer exception in auth module'); + const b = makeIssue('null pointer exception in auth module'); + expect(issuesSimilar(a, b)).toBe(true); + }); + + it('returns false for completely different issues', () => { + const a = makeIssue('Database connection timeout', { description: 'MySQL connection drops after 30s' }); + const b = makeIssue('UI button not rendering', { description: 'Submit button disappears on mobile' }); + expect(issuesSimilar(a, b)).toBe(false); + }); + + it('strips common prefixes before comparing', () => { + const a = makeIssue('error: null pointer exception'); + const b = makeIssue('bug: null pointer exception'); + // Both strip to "null pointer exception" — should be considered similar + expect(issuesSimilar(a, b)).toBe(true); + }); + + it('uses custom threshold when provided', () => { + const a = makeIssue('Some issue here', { description: 'partial match description' }); + const b = makeIssue('Some issue here', { description: 'completely different thing' }); + // At very low threshold, should match on title alone + expect(issuesSimilar(a, b, 0.1)).toBe(true); + // At very high threshold, partial description overlap may fail + expect(issuesSimilar(a, b, 0.99)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// isNoTestProject +// --------------------------------------------------------------------------- + +describe('isNoTestProject', () => { + const PROJECT_DIR = '/my-project'; + + beforeEach(() => { + mockExistsSync.mockReset().mockReturnValue(false); + mockReaddirSync.mockReset().mockReturnValue([]); + }); + + it('returns false when vitest.config.ts exists', () => { + mockExistsSync.mockImplementation((p: string) => p.endsWith('vitest.config.ts')); + expect(isNoTestProject('/spec', PROJECT_DIR)).toBe(false); + }); + + it('returns false when jest.config.js exists', () => { + mockExistsSync.mockImplementation((p: string) => p.endsWith('jest.config.js')); + expect(isNoTestProject('/spec', PROJECT_DIR)).toBe(false); + }); + + it('returns false when pytest.ini exists', () => { + mockExistsSync.mockImplementation((p: string) => p.endsWith('pytest.ini')); + expect(isNoTestProject('/spec', PROJECT_DIR)).toBe(false); + }); + + it('returns false when test files are found in __tests__ directory', () => { + mockExistsSync.mockImplementation((p: string) => p.endsWith('__tests__')); + mockReaddirSync.mockReturnValue(['auth.test.ts', 'utils.test.ts']); + expect(isNoTestProject('/spec', PROJECT_DIR)).toBe(false); + }); + + it('returns true when no test config files and no test directories exist', () => { + mockExistsSync.mockReturnValue(false); + expect(isNoTestProject('/spec', PROJECT_DIR)).toBe(true); + }); + + it('returns true when test directories exist but contain no test files', () => { + mockExistsSync.mockImplementation((p: string) => p.endsWith('tests')); + mockReaddirSync.mockReturnValue(['README.md', 'fixtures.json']); + expect(isNoTestProject('/spec', PROJECT_DIR)).toBe(true); + }); + + it('handles readdir errors gracefully and returns true', () => { + mockExistsSync.mockImplementation((p: string) => p.endsWith('tests')); + mockReaddirSync.mockImplementation(() => { + throw new Error('Permission denied'); + }); + expect(isNoTestProject('/spec', PROJECT_DIR)).toBe(true); + }); +}); diff --git a/apps/desktop/src/main/ai/orchestration/__tests__/recovery-manager.test.ts b/apps/desktop/src/main/ai/orchestration/__tests__/recovery-manager.test.ts new file mode 100644 index 00000000..ba123685 --- /dev/null +++ b/apps/desktop/src/main/ai/orchestration/__tests__/recovery-manager.test.ts @@ -0,0 +1,500 @@ +import path from 'node:path'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks — declared before any imports that pull in the mocked modules +// --------------------------------------------------------------------------- + +const mockReadFile = vi.fn(); +const mockWriteFile = vi.fn(); +const mockMkdir = vi.fn(); + +vi.mock('node:fs/promises', () => ({ + readFile: (...args: unknown[]) => mockReadFile(...args), + writeFile: (...args: unknown[]) => mockWriteFile(...args), + mkdir: (...args: unknown[]) => mockMkdir(...args), +})); + +vi.mock('../../utils/json-repair', () => ({ + safeParseJson: (raw: string) => { + try { + return JSON.parse(raw); + } catch { + return null; + } + }, +})); + +import { RecoveryManager } from '../recovery-manager'; +import type { BuildCheckpoint, FailureType } from '../recovery-manager'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const PROJECT_DIR = path.join(path.sep, 'project'); +const SPEC_DIR = path.join(PROJECT_DIR, '.auto-claude', 'specs', '001-feature'); +const MEMORY_DIR = path.join(SPEC_DIR, 'memory'); +const ATTEMPT_HISTORY_PATH = path.join(MEMORY_DIR, 'attempt_history.json'); + +function makeHistory( + subtasks: Record>, + stuckSubtasks: string[] = [], +) { + return JSON.stringify({ + subtasks, + stuckSubtasks, + metadata: { createdAt: new Date().toISOString(), lastUpdated: new Date().toISOString() }, + }); +} + +function recentTimestamp() { + return new Date().toISOString(); +} + +function oldTimestamp() { + // 3 hours ago — outside the 2-hour window + return new Date(Date.now() - 3 * 60 * 60 * 1_000).toISOString(); +} + +function createManager() { + return new RecoveryManager(SPEC_DIR, PROJECT_DIR); +} + +// --------------------------------------------------------------------------- +// classifyFailure +// --------------------------------------------------------------------------- + +describe('RecoveryManager.classifyFailure', () => { + let manager: RecoveryManager; + + beforeEach(() => { + manager = createManager(); + }); + + const cases: Array<[string, FailureType]> = [ + ['SyntaxError: Unexpected token', 'broken_build'], + ['Module not found: react', 'broken_build'], + ['compilation error in main.ts', 'broken_build'], + ['cannot find module lodash', 'broken_build'], + // 'IndentationError' is not in the source's buildErrors list — removed + ['parse error in config.js', 'broken_build'], + + ['verification failed: response mismatch', 'verification_failed'], + ['AssertionError: expected 1 to equal 2', 'verification_failed'], + ['test failed: missing element', 'verification_failed'], + ['status code 404 received', 'verification_failed'], + + ['context window exceeded', 'context_exhausted'], + ['token limit reached', 'context_exhausted'], + ['maximum length of response reached', 'context_exhausted'], + + ['429 too many requests', 'rate_limited'], + ['rate limit exceeded', 'rate_limited'], + ['too many requests from your IP', 'rate_limited'], + + ['401 unauthorized access', 'auth_failure'], + ['auth token expired', 'auth_failure'], + + ['a totally random and obscure crash', 'unknown'], + ['', 'unknown'], + ]; + + it.each(cases)('classifies "%s" as %s', (error, expected) => { + expect(manager.classifyFailure(error, 'subtask-1')).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// Checkpoint save / load round-trip +// --------------------------------------------------------------------------- + +describe('RecoveryManager checkpoint round-trip', () => { + let manager: RecoveryManager; + + beforeEach(() => { + mockWriteFile.mockReset().mockResolvedValue(undefined); + mockReadFile.mockReset(); + manager = createManager(); + }); + + it('writes a parseable checkpoint and loads it back', async () => { + const checkpoint: BuildCheckpoint = { + specId: '001', + phase: 'coding', + lastCompletedSubtaskId: 'subtask-3', + totalSubtasks: 5, + completedSubtasks: 3, + stuckSubtasks: [], + timestamp: new Date().toISOString(), + isComplete: false, + }; + + // Save captures what was written + let writtenContent = ''; + mockWriteFile.mockImplementation((_path: string, content: string) => { + writtenContent = content; + return Promise.resolve(); + }); + + await manager.saveCheckpoint(checkpoint); + + // Verify writeFile was called with the progress file path + expect(mockWriteFile).toHaveBeenCalledWith( + path.join(SPEC_DIR, 'build-progress.txt'), + expect.stringContaining('spec_id: 001'), + 'utf-8', + ); + + // Now load the checkpoint from what was written + mockReadFile.mockResolvedValueOnce(writtenContent); + const loaded = await manager.loadCheckpoint(); + + expect(loaded).not.toBeNull(); + expect(loaded?.specId).toBe('001'); + expect(loaded?.phase).toBe('coding'); + expect(loaded?.lastCompletedSubtaskId).toBe('subtask-3'); + expect(loaded?.totalSubtasks).toBe(5); + expect(loaded?.completedSubtasks).toBe(3); + expect(loaded?.isComplete).toBe(false); + }); + + it('saves lastCompletedSubtaskId=null as "none" and reloads as null', async () => { + const checkpoint: BuildCheckpoint = { + specId: '002', + phase: 'planning', + lastCompletedSubtaskId: null, + totalSubtasks: 3, + completedSubtasks: 0, + stuckSubtasks: [], + timestamp: new Date().toISOString(), + isComplete: false, + }; + + let writtenContent = ''; + mockWriteFile.mockImplementation((_path: string, content: string) => { + writtenContent = content; + return Promise.resolve(); + }); + + await manager.saveCheckpoint(checkpoint); + expect(writtenContent).toContain('last_completed_subtask: none'); + + mockReadFile.mockResolvedValueOnce(writtenContent); + const loaded = await manager.loadCheckpoint(); + expect(loaded?.lastCompletedSubtaskId).toBeNull(); + }); + + it('returns null when no checkpoint file exists', async () => { + mockReadFile.mockRejectedValueOnce(new Error('ENOENT')); + const loaded = await manager.loadCheckpoint(); + expect(loaded).toBeNull(); + }); + + it('saves stuckSubtasks correctly', async () => { + const checkpoint: BuildCheckpoint = { + specId: '003', + phase: 'coding', + lastCompletedSubtaskId: null, + totalSubtasks: 4, + completedSubtasks: 1, + stuckSubtasks: ['subtask-1', 'subtask-2'], + timestamp: new Date().toISOString(), + isComplete: false, + }; + + let writtenContent = ''; + mockWriteFile.mockImplementation((_path: string, content: string) => { + writtenContent = content; + return Promise.resolve(); + }); + + await manager.saveCheckpoint(checkpoint); + + mockReadFile.mockResolvedValueOnce(writtenContent); + const loaded = await manager.loadCheckpoint(); + expect(loaded?.stuckSubtasks).toEqual(['subtask-1', 'subtask-2']); + }); +}); + +// --------------------------------------------------------------------------- +// Circular fix detection +// --------------------------------------------------------------------------- + +describe('RecoveryManager.isCircularFix', () => { + let manager: RecoveryManager; + + beforeEach(() => { + mockReadFile.mockReset(); + mockWriteFile.mockReset().mockResolvedValue(undefined); + manager = createManager(); + }); + + it('returns false when fewer than 3 identical errors exist', async () => { + // Produce a real hash by calling classifyFailure indirectly + // We need the same hash that simpleHash("same error") would produce. + // We'll record 2 attempts with the same error, then check. + const sameError = 'same error message'; + + // Build a history with 2 records that share the same errorHash + // We compute the hash the same way the source does: via recordAttempt + // Here we mock the file system to return a pre-built history. + // For simplicity, we simulate 2 identical hashes manually. + const history = { + subtasks: { + 'task-1': [ + { timestamp: recentTimestamp(), error: sameError, failureType: 'unknown', errorHash: 'aaa' }, + { timestamp: recentTimestamp(), error: sameError, failureType: 'unknown', errorHash: 'aaa' }, + ], + }, + stuckSubtasks: [], + metadata: { createdAt: new Date().toISOString(), lastUpdated: new Date().toISOString() }, + }; + + mockReadFile.mockResolvedValue(JSON.stringify(history)); + const result = await manager.isCircularFix('task-1'); + expect(result).toBe(false); + }); + + it('returns true when 3 or more identical error hashes exist within the window', async () => { + const history = { + subtasks: { + 'task-1': [ + { timestamp: recentTimestamp(), error: 'err', failureType: 'unknown', errorHash: 'bbb' }, + { timestamp: recentTimestamp(), error: 'err', failureType: 'unknown', errorHash: 'bbb' }, + { timestamp: recentTimestamp(), error: 'err', failureType: 'unknown', errorHash: 'bbb' }, + ], + }, + stuckSubtasks: [], + metadata: { createdAt: new Date().toISOString(), lastUpdated: new Date().toISOString() }, + }; + + mockReadFile.mockResolvedValue(JSON.stringify(history)); + const result = await manager.isCircularFix('task-1'); + expect(result).toBe(true); + }); + + it('ignores attempts outside the 2-hour window', async () => { + const history = { + subtasks: { + 'task-1': [ + // Two old entries — outside window + { timestamp: oldTimestamp(), error: 'err', failureType: 'unknown', errorHash: 'ccc' }, + { timestamp: oldTimestamp(), error: 'err', failureType: 'unknown', errorHash: 'ccc' }, + { timestamp: oldTimestamp(), error: 'err', failureType: 'unknown', errorHash: 'ccc' }, + // One recent entry + { timestamp: recentTimestamp(), error: 'err', failureType: 'unknown', errorHash: 'ccc' }, + ], + }, + stuckSubtasks: [], + metadata: { createdAt: new Date().toISOString(), lastUpdated: new Date().toISOString() }, + }; + + mockReadFile.mockResolvedValue(JSON.stringify(history)); + const result = await manager.isCircularFix('task-1'); + // Only 1 recent entry → not circular + expect(result).toBe(false); + }); + + it('returns false for a subtask with no attempt history', async () => { + mockReadFile.mockResolvedValue(makeHistory({})); + const result = await manager.isCircularFix('no-such-task'); + expect(result).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Attempt window filtering via getAttemptCount +// --------------------------------------------------------------------------- + +describe('RecoveryManager.getAttemptCount', () => { + let manager: RecoveryManager; + + beforeEach(() => { + mockReadFile.mockReset(); + mockWriteFile.mockReset().mockResolvedValue(undefined); + manager = createManager(); + }); + + it('counts only recent attempts within the 2-hour window', async () => { + const history = { + subtasks: { + 'task-x': [ + { timestamp: oldTimestamp(), error: 'old error', failureType: 'unknown', errorHash: 'h1' }, + { timestamp: recentTimestamp(), error: 'new error 1', failureType: 'unknown', errorHash: 'h2' }, + { timestamp: recentTimestamp(), error: 'new error 2', failureType: 'unknown', errorHash: 'h3' }, + ], + }, + stuckSubtasks: [], + metadata: { createdAt: new Date().toISOString(), lastUpdated: new Date().toISOString() }, + }; + + mockReadFile.mockResolvedValue(JSON.stringify(history)); + const count = await manager.getAttemptCount('task-x'); + expect(count).toBe(2); + }); + + it('returns 0 for unknown subtask', async () => { + mockReadFile.mockResolvedValue(makeHistory({})); + const count = await manager.getAttemptCount('ghost-task'); + expect(count).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// determineRecoveryAction +// --------------------------------------------------------------------------- + +describe('RecoveryManager.determineRecoveryAction', () => { + let manager: RecoveryManager; + + beforeEach(() => { + mockReadFile.mockReset(); + mockWriteFile.mockReset().mockResolvedValue(undefined); + manager = createManager(); + }); + + it('escalates immediately when circular fix detected', async () => { + // 3 identical error hashes → circular + const history = { + subtasks: { + 'task-circ': [ + { timestamp: recentTimestamp(), error: 'err', failureType: 'unknown', errorHash: 'xyz' }, + { timestamp: recentTimestamp(), error: 'err', failureType: 'unknown', errorHash: 'xyz' }, + { timestamp: recentTimestamp(), error: 'err', failureType: 'unknown', errorHash: 'xyz' }, + ], + }, + stuckSubtasks: [], + metadata: { createdAt: new Date().toISOString(), lastUpdated: new Date().toISOString() }, + }; + mockReadFile.mockResolvedValue(JSON.stringify(history)); + + const action = await manager.determineRecoveryAction('task-circ', 'err', 5); + expect(action.action).toBe('escalate'); + expect(action.reason).toMatch(/circular/i); + }); + + it('skips when attempt count >= maxRetries', async () => { + const history = { + subtasks: { + 'task-skip': [ + { timestamp: recentTimestamp(), error: 'fail', failureType: 'unknown', errorHash: 'a1' }, + { timestamp: recentTimestamp(), error: 'fail', failureType: 'unknown', errorHash: 'a2' }, + { timestamp: recentTimestamp(), error: 'fail', failureType: 'unknown', errorHash: 'a3' }, + ], + }, + stuckSubtasks: [], + metadata: { createdAt: new Date().toISOString(), lastUpdated: new Date().toISOString() }, + }; + mockReadFile.mockResolvedValue(JSON.stringify(history)); + + const action = await manager.determineRecoveryAction('task-skip', 'fail', 3); + expect(action.action).toBe('skip'); + expect(action.reason).toMatch(/max retries/i); + }); + + it('escalates on auth failure', async () => { + mockReadFile.mockResolvedValue(makeHistory({ 'task-auth': [] })); + const action = await manager.determineRecoveryAction('task-auth', '401 unauthorized', 5); + expect(action.action).toBe('escalate'); + expect(action.reason).toMatch(/auth/i); + }); + + it('retries on rate limit', async () => { + mockReadFile.mockResolvedValue(makeHistory({ 'task-rl': [] })); + const action = await manager.determineRecoveryAction('task-rl', '429 rate limit exceeded', 5); + expect(action.action).toBe('retry'); + expect(action.reason).toMatch(/rate limit/i); + }); + + it('retries on context exhaustion', async () => { + mockReadFile.mockResolvedValue(makeHistory({ 'task-ctx': [] })); + const action = await manager.determineRecoveryAction('task-ctx', 'context window exceeded', 5); + expect(action.action).toBe('retry'); + expect(action.reason).toMatch(/context/i); + }); + + it('defaults to retry for unknown failure types', async () => { + mockReadFile.mockResolvedValue(makeHistory({ 'task-unk': [] })); + const action = await manager.determineRecoveryAction('task-unk', 'something weird', 5); + expect(action.action).toBe('retry'); + expect(action.target).toBe('task-unk'); + }); +}); + +// --------------------------------------------------------------------------- +// init — directory creation and history bootstrap +// --------------------------------------------------------------------------- + +describe('RecoveryManager.init', () => { + let manager: RecoveryManager; + + beforeEach(() => { + mockMkdir.mockReset().mockResolvedValue(undefined); + mockReadFile.mockReset(); + mockWriteFile.mockReset().mockResolvedValue(undefined); + manager = createManager(); + }); + + it('creates memory directory with recursive flag', async () => { + // Simulate history file already existing + mockReadFile.mockResolvedValueOnce(makeHistory({})); + await manager.init(); + expect(mockMkdir).toHaveBeenCalledWith(MEMORY_DIR, { recursive: true }); + }); + + it('writes an empty history when no history file exists', async () => { + mockReadFile.mockRejectedValueOnce(new Error('ENOENT')); + await manager.init(); + expect(mockWriteFile).toHaveBeenCalledWith( + ATTEMPT_HISTORY_PATH, + expect.stringContaining('"subtasks"'), + 'utf-8', + ); + }); +}); + +// --------------------------------------------------------------------------- +// markStuck / isStuck +// --------------------------------------------------------------------------- + +describe('RecoveryManager stuck tracking', () => { + let manager: RecoveryManager; + + beforeEach(() => { + mockReadFile.mockReset(); + mockWriteFile.mockReset().mockResolvedValue(undefined); + manager = createManager(); + }); + + it('marks a subtask as stuck and detects it', async () => { + let storedHistory = makeHistory({}, []); + + mockReadFile.mockImplementation(() => Promise.resolve(storedHistory)); + mockWriteFile.mockImplementation((_path: string, content: string) => { + storedHistory = content; + return Promise.resolve(); + }); + + await manager.markStuck('task-stuck'); + + expect(await manager.isStuck('task-stuck')).toBe(true); + expect(await manager.isStuck('task-fine')).toBe(false); + }); + + it('does not duplicate a subtask when marked stuck twice', async () => { + let storedHistory = makeHistory({}, []); + + mockReadFile.mockImplementation(() => Promise.resolve(storedHistory)); + mockWriteFile.mockImplementation((_path: string, content: string) => { + storedHistory = content; + return Promise.resolve(); + }); + + await manager.markStuck('task-dup'); + await manager.markStuck('task-dup'); + + const parsed = JSON.parse(storedHistory) as { stuckSubtasks: string[] }; + expect(parsed.stuckSubtasks.filter((id) => id === 'task-dup')).toHaveLength(1); + }); +}); diff --git a/apps/desktop/src/main/ai/runners/__tests__/changelog.test.ts b/apps/desktop/src/main/ai/runners/__tests__/changelog.test.ts new file mode 100644 index 00000000..b09a953f --- /dev/null +++ b/apps/desktop/src/main/ai/runners/__tests__/changelog.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ============================================================================= +// Mocks — must be declared before any imports that use them +// ============================================================================= + +const mockGenerateText = vi.fn(); + +vi.mock('ai', () => ({ + generateText: (...args: unknown[]) => mockGenerateText(...args), +})); + +const mockCreateSimpleClient = vi.fn(); + +vi.mock('../../client/factory', () => ({ + createSimpleClient: (...args: unknown[]) => mockCreateSimpleClient(...args), +})); + +// ============================================================================= +// Import after mocking +// ============================================================================= + +import { generateChangelog } from '../changelog'; +import type { ChangelogConfig } from '../changelog'; + +// ============================================================================= +// Helpers +// ============================================================================= + +/** A fake model object used by the mock client */ +const fakeModel = { modelId: 'claude-haiku-test' }; + +function makeMockClient(systemPrompt = 'You are a technical writer.') { + return { model: fakeModel, systemPrompt }; +} + +function baseConfig(overrides: Partial = {}): ChangelogConfig { + return { + projectName: 'TestProject', + version: '1.0.0', + sourceMode: 'tasks', + tasks: [ + { title: 'Add dark mode', description: 'Implemented dark mode toggle', category: 'feature' }, + ], + ...overrides, + }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('generateChangelog', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreateSimpleClient.mockResolvedValue(makeMockClient()); + }); + + // --------------------------------------------------------------------------- + // Successful generation + // --------------------------------------------------------------------------- + + it('returns success with trimmed text when LLM responds', async () => { + mockGenerateText.mockResolvedValue({ text: ' ## [1.0.0]\n\n### Added\n- Dark mode\n ' }); + + const result = await generateChangelog(baseConfig()); + + expect(result.success).toBe(true); + expect(result.text).toBe('## [1.0.0]\n\n### Added\n- Dark mode'); + expect(result.error).toBeUndefined(); + }); + + it('passes project name and version in the prompt to createSimpleClient', async () => { + mockGenerateText.mockResolvedValue({ text: '## [2.0.0]' }); + + await generateChangelog(baseConfig({ projectName: 'MyApp', version: '2.0.0' })); + + // createSimpleClient receives system-level configuration + expect(mockCreateSimpleClient).toHaveBeenCalledOnce(); + const clientArgs = mockCreateSimpleClient.mock.calls[0][0]; + expect(clientArgs).toHaveProperty('modelShorthand'); + expect(clientArgs).toHaveProperty('thinkingLevel'); + }); + + it('passes model and systemPrompt from client to generateText', async () => { + mockGenerateText.mockResolvedValue({ text: '## [1.0.0]' }); + + await generateChangelog(baseConfig()); + + expect(mockGenerateText).toHaveBeenCalledOnce(); + const callArgs = mockGenerateText.mock.calls[0][0]; + expect(callArgs.model).toBe(fakeModel); + expect(callArgs.system).toBe('You are a technical writer.'); + expect(callArgs.prompt).toContain('TestProject'); + expect(callArgs.prompt).toContain('1.0.0'); + }); + + // --------------------------------------------------------------------------- + // Task mode — prompt content + // --------------------------------------------------------------------------- + + it('includes task titles and categories in prompt for tasks mode', async () => { + mockGenerateText.mockResolvedValue({ text: '## [1.0.0]' }); + + const config = baseConfig({ + tasks: [ + { title: 'My feature', description: 'desc', category: 'feature', issueNumber: 42 }, + ], + }); + await generateChangelog(config); + + const prompt = mockGenerateText.mock.calls[0][0].prompt as string; + expect(prompt).toContain('My feature'); + expect(prompt).toContain('feature'); + expect(prompt).toContain('#42'); + }); + + // --------------------------------------------------------------------------- + // Git history / branch-diff modes + // --------------------------------------------------------------------------- + + it('includes commit messages in prompt for git-history mode', async () => { + mockGenerateText.mockResolvedValue({ text: '## [1.0.0]' }); + + await generateChangelog( + baseConfig({ sourceMode: 'git-history', commits: 'feat: add login\nfix: bug #5' }), + ); + + const prompt = mockGenerateText.mock.calls[0][0].prompt as string; + expect(prompt).toContain('feat: add login'); + }); + + it('truncates commits to 5000 chars', async () => { + mockGenerateText.mockResolvedValue({ text: '## [1.0.0]' }); + const longCommits = 'x'.repeat(10_000); + + await generateChangelog(baseConfig({ sourceMode: 'branch-diff', commits: longCommits })); + + const prompt = mockGenerateText.mock.calls[0][0].prompt as string; + // The 'x'.repeat(10000) block should be truncated — prompt must not exceed + // 5000 'x' chars plus surrounding text + const xCount = (prompt.match(/x/g) ?? []).length; + expect(xCount).toBeLessThanOrEqual(5000); + }); + + // --------------------------------------------------------------------------- + // Previous changelog style reference + // --------------------------------------------------------------------------- + + it('includes previousChangelog when provided', async () => { + mockGenerateText.mockResolvedValue({ text: '## [1.0.0]' }); + + await generateChangelog( + baseConfig({ previousChangelog: '## [0.9.0]\n\n### Added\n- Old feature' }), + ); + + const prompt = mockGenerateText.mock.calls[0][0].prompt as string; + expect(prompt).toContain('Previous Changelog'); + expect(prompt).toContain('0.9.0'); + }); + + // --------------------------------------------------------------------------- + // Default model / thinking level + // --------------------------------------------------------------------------- + + it('uses sonnet model and low thinking level by default', async () => { + mockGenerateText.mockResolvedValue({ text: '## [1.0.0]' }); + + await generateChangelog(baseConfig()); + + const clientArgs = mockCreateSimpleClient.mock.calls[0][0]; + expect(clientArgs.modelShorthand).toBe('sonnet'); + expect(clientArgs.thinkingLevel).toBe('low'); + }); + + it('accepts custom modelShorthand and thinkingLevel', async () => { + mockGenerateText.mockResolvedValue({ text: '## [1.0.0]' }); + + await generateChangelog(baseConfig({ modelShorthand: 'haiku', thinkingLevel: 'high' })); + + const clientArgs = mockCreateSimpleClient.mock.calls[0][0]; + expect(clientArgs.modelShorthand).toBe('haiku'); + expect(clientArgs.thinkingLevel).toBe('high'); + }); + + // --------------------------------------------------------------------------- + // Empty response handling + // --------------------------------------------------------------------------- + + it('returns failure when LLM returns empty text', async () => { + mockGenerateText.mockResolvedValue({ text: ' ' }); + + const result = await generateChangelog(baseConfig()); + + expect(result.success).toBe(false); + expect(result.text).toBe(''); + expect(result.error).toBe('Empty response from AI'); + }); + + // --------------------------------------------------------------------------- + // Error handling + // --------------------------------------------------------------------------- + + it('returns failure with error message when generateText throws', async () => { + mockGenerateText.mockRejectedValue(new Error('Rate limit exceeded')); + + const result = await generateChangelog(baseConfig()); + + expect(result.success).toBe(false); + expect(result.text).toBe(''); + expect(result.error).toBe('Rate limit exceeded'); + }); + + it('returns failure with string coercion when non-Error is thrown', async () => { + mockGenerateText.mockRejectedValue('timeout'); + + const result = await generateChangelog(baseConfig()); + + expect(result.success).toBe(false); + expect(result.error).toBe('timeout'); + }); + + it('returns failure when createSimpleClient throws', async () => { + mockCreateSimpleClient.mockRejectedValue(new Error('No auth available')); + + const result = await generateChangelog(baseConfig()); + + expect(result.success).toBe(false); + expect(result.error).toBe('No auth available'); + }); +}); diff --git a/apps/desktop/src/main/ai/runners/__tests__/commit-message.test.ts b/apps/desktop/src/main/ai/runners/__tests__/commit-message.test.ts new file mode 100644 index 00000000..82107644 --- /dev/null +++ b/apps/desktop/src/main/ai/runners/__tests__/commit-message.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ============================================================================= +// Mocks — must be declared before any imports that use them +// ============================================================================= + +const mockGenerateText = vi.fn(); + +vi.mock('ai', () => ({ + generateText: (...args: unknown[]) => mockGenerateText(...args), +})); + +const mockCreateSimpleClient = vi.fn(); + +vi.mock('../../client/factory', () => ({ + createSimpleClient: (...args: unknown[]) => mockCreateSimpleClient(...args), +})); + +// Mock filesystem access so tests are hermetic +const mockExistsSync = vi.fn(); +const mockReadFileSync = vi.fn(); + +vi.mock('node:fs', () => ({ + existsSync: (...args: unknown[]) => mockExistsSync(...args), + readFileSync: (...args: unknown[]) => mockReadFileSync(...args), +})); + +// json-repair is used by the commit-message runner for safeParseJson +vi.mock('../../../utils/json-repair', () => ({ + safeParseJson: (text: string) => { + try { + return JSON.parse(text); + } catch { + return null; + } + }, +})); + +// ============================================================================= +// Import after mocking +// ============================================================================= + +import { generateCommitMessage } from '../commit-message'; +import type { CommitMessageConfig } from '../commit-message'; + +// ============================================================================= +// Helpers +// ============================================================================= + +const fakeModel = { modelId: 'claude-haiku-test' }; + +function makeMockClient(systemPrompt = 'You are a Git expert.') { + return { model: fakeModel, systemPrompt }; +} + +function baseConfig(overrides: Partial = {}): CommitMessageConfig { + return { + projectDir: '/project', + specName: '001-add-feature', + diffSummary: '+5 -2 src/app.ts', + filesChanged: ['src/app.ts', 'src/utils.ts'], + ...overrides, + }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('generateCommitMessage', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreateSimpleClient.mockResolvedValue(makeMockClient()); + // By default, spec directory does not exist + mockExistsSync.mockReturnValue(false); + }); + + // --------------------------------------------------------------------------- + // Successful generation + // --------------------------------------------------------------------------- + + it('returns trimmed AI-generated commit message on success', async () => { + mockGenerateText.mockResolvedValue({ + text: ' feat(app): add authentication flow\n\nImplemented OAuth2.\n ', + }); + + const result = await generateCommitMessage(baseConfig()); + + expect(result).toBe('feat(app): add authentication flow\n\nImplemented OAuth2.'); + }); + + it('passes model and systemPrompt from client to generateText', async () => { + mockGenerateText.mockResolvedValue({ text: 'feat: something' }); + + await generateCommitMessage(baseConfig()); + + expect(mockGenerateText).toHaveBeenCalledOnce(); + const callArgs = mockGenerateText.mock.calls[0][0]; + expect(callArgs.model).toBe(fakeModel); + expect(callArgs.system).toBe('You are a Git expert.'); + }); + + it('includes diffSummary in the prompt sent to generateText', async () => { + mockGenerateText.mockResolvedValue({ text: 'fix: resolve bug' }); + + await generateCommitMessage(baseConfig({ diffSummary: 'removed null check in auth.ts' })); + + const prompt = mockGenerateText.mock.calls[0][0].prompt as string; + expect(prompt).toContain('removed null check in auth.ts'); + }); + + it('includes filesChanged in the prompt', async () => { + mockGenerateText.mockResolvedValue({ text: 'refactor: split utilities' }); + + await generateCommitMessage( + baseConfig({ filesChanged: ['src/auth.ts', 'src/utils.ts', 'src/index.ts'] }), + ); + + const prompt = mockGenerateText.mock.calls[0][0].prompt as string; + expect(prompt).toContain('src/auth.ts'); + }); + + // --------------------------------------------------------------------------- + // Default model / thinking level + // --------------------------------------------------------------------------- + + it('uses haiku model and low thinking level by default', async () => { + mockGenerateText.mockResolvedValue({ text: 'chore: update deps' }); + + await generateCommitMessage(baseConfig()); + + const clientArgs = mockCreateSimpleClient.mock.calls[0][0]; + expect(clientArgs.modelShorthand).toBe('haiku'); + expect(clientArgs.thinkingLevel).toBe('low'); + }); + + it('accepts custom modelShorthand and thinkingLevel', async () => { + mockGenerateText.mockResolvedValue({ text: 'feat: new endpoint' }); + + await generateCommitMessage(baseConfig({ modelShorthand: 'sonnet', thinkingLevel: 'medium' })); + + const clientArgs = mockCreateSimpleClient.mock.calls[0][0]; + expect(clientArgs.modelShorthand).toBe('sonnet'); + expect(clientArgs.thinkingLevel).toBe('medium'); + }); + + // --------------------------------------------------------------------------- + // GitHub issue handling + // --------------------------------------------------------------------------- + + it('includes Fixes reference when githubIssue is provided', async () => { + mockGenerateText.mockResolvedValue({ text: 'fix: null pointer\n\nFixes #99' }); + + const result = await generateCommitMessage(baseConfig({ githubIssue: 99 })); + + expect(result).toContain('Fixes #99'); + }); + + // --------------------------------------------------------------------------- + // Spec file context + // --------------------------------------------------------------------------- + + it('reads spec.md for title when spec directory exists', async () => { + // Spec directory at .auto-claude/specs/001-add-feature + mockExistsSync.mockImplementation((p: string) => { + const normalized = p.replace(/\\/g, '/'); + if (normalized.includes('specs/001-add-feature')) return true; + return false; + }); + mockReadFileSync.mockImplementation((p: string) => { + if (p.includes('spec.md')) return '# Add OAuth Feature\n\n## Overview\nFull OAuth2 support.'; + return '{}'; + }); + mockGenerateText.mockResolvedValue({ text: 'feat(auth): add OAuth2' }); + + const result = await generateCommitMessage(baseConfig()); + + // Result should come from LLM (title from spec was available for context) + expect(result).toBe('feat(auth): add OAuth2'); + const prompt = mockGenerateText.mock.calls[0][0].prompt as string; + expect(prompt).toContain('Add OAuth Feature'); + }); + + // --------------------------------------------------------------------------- + // Fallback message + // --------------------------------------------------------------------------- + + it('returns fallback message when generateText throws', async () => { + mockGenerateText.mockRejectedValue(new Error('Network error')); + + const result = await generateCommitMessage(baseConfig({ specName: '001-add-feature' })); + + // Fallback format: ": " + expect(result).toMatch(/^(feat|fix|refactor|docs|test|perf|chore|style|ci|build):/); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); + + it('includes Fixes in fallback when githubIssue provided and LLM fails', async () => { + mockGenerateText.mockRejectedValue(new Error('Timeout')); + + const result = await generateCommitMessage(baseConfig({ githubIssue: 77 })); + + expect(result).toContain('Fixes #77'); + }); + + it('returns fallback when LLM returns empty text', async () => { + mockGenerateText.mockResolvedValue({ text: ' ' }); + + const result = await generateCommitMessage(baseConfig()); + + // Should fall through to fallback + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); + + // --------------------------------------------------------------------------- + // Large filesChanged list + // --------------------------------------------------------------------------- + + it('truncates filesChanged list when more than 20 files', async () => { + mockGenerateText.mockResolvedValue({ text: 'refactor: big cleanup' }); + + const manyFiles = Array.from({ length: 30 }, (_, i) => `src/file${i}.ts`); + await generateCommitMessage(baseConfig({ filesChanged: manyFiles })); + + const prompt = mockGenerateText.mock.calls[0][0].prompt as string; + expect(prompt).toContain('and 10 more files'); + }); +}); diff --git a/apps/desktop/src/main/ai/runners/__tests__/ideation.test.ts b/apps/desktop/src/main/ai/runners/__tests__/ideation.test.ts new file mode 100644 index 00000000..85dedd27 --- /dev/null +++ b/apps/desktop/src/main/ai/runners/__tests__/ideation.test.ts @@ -0,0 +1,306 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ============================================================================= +// Mocks — must be declared before any imports that use them +// ============================================================================= + +const mockStreamText = vi.fn(); + +vi.mock('ai', () => ({ + streamText: (...args: unknown[]) => mockStreamText(...args), + stepCountIs: (n: number) => ({ type: 'stepCount', count: n }), +})); + +const mockCreateSimpleClient = vi.fn(); + +vi.mock('../../client/factory', () => ({ + createSimpleClient: (...args: unknown[]) => mockCreateSimpleClient(...args), +})); + +// Mock filesystem: prompt files exist by default +const mockExistsSync = vi.fn(); +const mockReadFileSync = vi.fn(); + +vi.mock('node:fs', () => ({ + existsSync: (...args: unknown[]) => mockExistsSync(...args), + readFileSync: (...args: unknown[]) => mockReadFileSync(...args), +})); + +// Mock the tool registry so we don't need real tool initialization +vi.mock('../../tools/build-registry', () => ({ + buildToolRegistry: () => ({ + getToolsForAgent: vi.fn().mockReturnValue({}), + }), +})); + +// ============================================================================= +// Import after mocking +// ============================================================================= + +import { runIdeation, IDEATION_TYPES, IDEATION_TYPE_LABELS } from '../ideation'; +import type { IdeationConfig, IdeationStreamEvent } from '../ideation'; + +// ============================================================================= +// Helpers +// ============================================================================= + +const fakeModel = { modelId: 'claude-sonnet-test' }; + +function makeMockClient() { + return { + model: fakeModel, + systemPrompt: '', + tools: {}, + maxSteps: 30, + }; +} + +/** + * Build an async generator that yields stream parts and then ends. + */ +function makeStream(parts: Array<Record<string, unknown>>) { + return { + fullStream: (async function* () { + for (const part of parts) { + yield part; + } + })(), + }; +} + +function baseConfig(overrides: Partial<IdeationConfig> = {}): IdeationConfig { + return { + projectDir: '/project', + outputDir: '/project/.auto-claude/ideation', + promptsDir: '/app/prompts', + ideationType: 'code_improvements', + ...overrides, + }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('runIdeation', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreateSimpleClient.mockResolvedValue(makeMockClient()); + // Prompt file exists and has content by default + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('Analyze the codebase for improvements.'); + }); + + // --------------------------------------------------------------------------- + // Constants + // --------------------------------------------------------------------------- + + it('exports all expected IDEATION_TYPES', () => { + expect(IDEATION_TYPES).toContain('code_improvements'); + expect(IDEATION_TYPES).toContain('ui_ux_improvements'); + expect(IDEATION_TYPES).toContain('documentation_gaps'); + expect(IDEATION_TYPES).toContain('security_hardening'); + expect(IDEATION_TYPES).toContain('performance_optimizations'); + expect(IDEATION_TYPES).toContain('code_quality'); + expect(IDEATION_TYPES).toHaveLength(6); + }); + + it('exports human-readable labels for all ideation types', () => { + for (const type of IDEATION_TYPES) { + expect(IDEATION_TYPE_LABELS[type]).toBeTruthy(); + } + }); + + // --------------------------------------------------------------------------- + // Successful run + // --------------------------------------------------------------------------- + + it('returns success with accumulated text from stream', async () => { + mockStreamText.mockReturnValue( + makeStream([ + { type: 'text-delta', text: 'Found ' }, + { type: 'text-delta', text: '3 improvements.' }, + ]), + ); + + const result = await runIdeation(baseConfig()); + + expect(result.success).toBe(true); + expect(result.text).toBe('Found 3 improvements.'); + expect(result.error).toBeUndefined(); + }); + + it('calls createSimpleClient with sonnet and medium thinking by default', async () => { + mockStreamText.mockReturnValue(makeStream([])); + + await runIdeation(baseConfig()); + + const clientArgs = mockCreateSimpleClient.mock.calls[0][0]; + expect(clientArgs.modelShorthand).toBe('sonnet'); + expect(clientArgs.thinkingLevel).toBe('medium'); + }); + + it('accepts custom modelShorthand and thinkingLevel', async () => { + mockStreamText.mockReturnValue(makeStream([])); + + await runIdeation(baseConfig({ modelShorthand: 'haiku', thinkingLevel: 'low' })); + + const clientArgs = mockCreateSimpleClient.mock.calls[0][0]; + expect(clientArgs.modelShorthand).toBe('haiku'); + expect(clientArgs.thinkingLevel).toBe('low'); + }); + + it('passes tools from client to streamText', async () => { + mockStreamText.mockReturnValue(makeStream([])); + + await runIdeation(baseConfig()); + + const streamArgs = mockStreamText.mock.calls[0][0]; + expect(streamArgs).toHaveProperty('tools'); + expect(streamArgs).toHaveProperty('model'); + }); + + // --------------------------------------------------------------------------- + // Stream callbacks + // --------------------------------------------------------------------------- + + it('forwards text-delta events to onStream callback', async () => { + mockStreamText.mockReturnValue( + makeStream([ + { type: 'text-delta', text: 'hello' }, + { type: 'text-delta', text: ' world' }, + ]), + ); + + const events: IdeationStreamEvent[] = []; + await runIdeation(baseConfig(), (e) => events.push(e)); + + const textEvents = events.filter((e) => e.type === 'text-delta'); + expect(textEvents).toHaveLength(2); + expect((textEvents[0] as { type: 'text-delta'; text: string }).text).toBe('hello'); + }); + + it('forwards tool-use events from tool-call stream parts', async () => { + mockStreamText.mockReturnValue( + makeStream([{ type: 'tool-call', toolName: 'Glob', toolCallId: 'c1', input: {} }]), + ); + + const events: IdeationStreamEvent[] = []; + await runIdeation(baseConfig(), (e) => events.push(e)); + + const toolEvents = events.filter((e) => e.type === 'tool-use'); + expect(toolEvents).toHaveLength(1); + expect((toolEvents[0] as { type: 'tool-use'; name: string }).name).toBe('Glob'); + }); + + it('forwards error events from stream error parts', async () => { + mockStreamText.mockReturnValue( + makeStream([{ type: 'error', error: new Error('stream error') }]), + ); + + const events: IdeationStreamEvent[] = []; + await runIdeation(baseConfig(), (e) => events.push(e)); + + const errorEvents = events.filter((e) => e.type === 'error'); + expect(errorEvents).toHaveLength(1); + expect((errorEvents[0] as { type: 'error'; error: string }).error).toBe('stream error'); + }); + + // --------------------------------------------------------------------------- + // Prompt file not found + // --------------------------------------------------------------------------- + + it('returns failure when prompt file does not exist', async () => { + mockExistsSync.mockReturnValue(false); + + const result = await runIdeation(baseConfig()); + + expect(result.success).toBe(false); + expect(result.text).toBe(''); + expect(result.error).toContain('Prompt not found'); + }); + + it('returns failure when prompt file cannot be read', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockImplementation(() => { + throw new Error('Permission denied'); + }); + + const result = await runIdeation(baseConfig()); + + expect(result.success).toBe(false); + expect(result.error).toContain('Permission denied'); + }); + + // --------------------------------------------------------------------------- + // Error handling — streamText throws + // --------------------------------------------------------------------------- + + it('returns failure when streamText iteration throws', async () => { + mockStreamText.mockReturnValue({ + // biome-ignore lint/correctness/useYield: intentionally throwing before yield to test error path + fullStream: (async function* () { + throw new Error('API error'); + })(), + }); + + const result = await runIdeation(baseConfig()); + + expect(result.success).toBe(false); + expect(result.error).toBe('API error'); + }); + + it('emits error event to callback when streamText throws', async () => { + mockStreamText.mockReturnValue({ + // biome-ignore lint/correctness/useYield: intentionally throwing before yield to test error path + fullStream: (async function* () { + throw new Error('network failure'); + })(), + }); + + const events: IdeationStreamEvent[] = []; + await runIdeation(baseConfig(), (e) => events.push(e)); + + expect(events.some((e) => e.type === 'error')).toBe(true); + }); + + // --------------------------------------------------------------------------- + // Ideation type routing — checks the correct prompt file is loaded + // --------------------------------------------------------------------------- + + it.each(IDEATION_TYPES)('loads the correct prompt file for ideation type: %s', async (type) => { + mockStreamText.mockReturnValue(makeStream([])); + + await runIdeation(baseConfig({ ideationType: type })); + + // The prompt file for each type should have been checked for existence + expect(mockExistsSync).toHaveBeenCalledWith(expect.stringContaining('.md')); + }); + + // --------------------------------------------------------------------------- + // Context injection + // --------------------------------------------------------------------------- + + it('includes projectDir and outputDir in the prompt passed to streamText', async () => { + mockStreamText.mockReturnValue(makeStream([])); + + await runIdeation( + baseConfig({ projectDir: '/my/project', outputDir: '/my/project/.auto-claude/ideation' }), + ); + + // The system prompt passed to streamText should contain the project dir + const streamArgs = mockStreamText.mock.calls[0][0]; + const systemPrompt = streamArgs.system as string; + expect(systemPrompt).toContain('/my/project'); + }); + + it('injects maxIdeasPerType into the context', async () => { + mockStreamText.mockReturnValue(makeStream([])); + + await runIdeation(baseConfig({ maxIdeasPerType: 10 })); + + const streamArgs = mockStreamText.mock.calls[0][0]; + const systemPrompt = streamArgs.system as string; + expect(systemPrompt).toContain('10'); + }); +}); diff --git a/apps/desktop/src/main/ai/runners/__tests__/insight-extractor.test.ts b/apps/desktop/src/main/ai/runners/__tests__/insight-extractor.test.ts new file mode 100644 index 00000000..36bc244f --- /dev/null +++ b/apps/desktop/src/main/ai/runners/__tests__/insight-extractor.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ============================================================================= +// Mocks — must be declared before any imports that use them +// ============================================================================= + +const mockGenerateText = vi.fn(); + +vi.mock('ai', () => ({ + generateText: (...args: unknown[]) => mockGenerateText(...args), + Output: { + object: ({ schema }: { schema: unknown }) => ({ type: 'object', schema }), + }, +})); + +const mockCreateSimpleClient = vi.fn(); + +vi.mock('../../client/factory', () => ({ + createSimpleClient: (...args: unknown[]) => mockCreateSimpleClient(...args), +})); + +// Mock schema/structured-output so we don't need the actual implementation +vi.mock('../../schema/structured-output', () => ({ + parseLLMJson: vi.fn().mockReturnValue(null), +})); + +// Mock the Zod schemas used by the runner +vi.mock('../../schema/insight-extractor', () => ({ + ExtractedInsightsSchema: {}, +})); + +vi.mock('../../schema/output', () => ({ + ExtractedInsightsOutputSchema: {}, +})); + +// ============================================================================= +// Import after mocking +// ============================================================================= + +import { extractSessionInsights } from '../insight-extractor'; +import type { InsightExtractionConfig } from '../insight-extractor'; +import { parseLLMJson } from '../../schema/structured-output'; + +// ============================================================================= +// Helpers +// ============================================================================= + +const fakeModel = { modelId: 'claude-haiku-test' }; + +function makeMockClient() { + return { model: fakeModel, systemPrompt: 'You are an expert code analyst.' }; +} + +function makeValidOutput() { + return { + file_insights: [{ file: 'src/app.ts', insight: 'Uses singleton pattern', category: 'pattern' }], + patterns_discovered: ['Singleton pattern used'], + gotchas_discovered: ['Must call init() before use'], + approach_outcome: { + success: true, + approach_used: 'Direct refactor', + why_it_worked: 'Simplified the module', + why_it_failed: null, + alternatives_tried: [], + }, + recommendations: ['Add unit tests for singleton'], + }; +} + +function baseConfig(overrides: Partial<InsightExtractionConfig> = {}): InsightExtractionConfig { + return { + subtaskId: 'sub-001', + subtaskDescription: 'Refactor authentication module', + sessionNum: 1, + success: true, + diff: 'diff --git a/src/auth.ts b/src/auth.ts\n+ return token;', + changedFiles: ['src/auth.ts'], + commitMessages: 'refactor: simplify auth module', + attemptHistory: [], + ...overrides, + }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('extractSessionInsights', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreateSimpleClient.mockResolvedValue(makeMockClient()); + // By default, result.output contains the structured data (constrained decoding path) + mockGenerateText.mockResolvedValue({ + output: makeValidOutput(), + text: '', + }); + }); + + // --------------------------------------------------------------------------- + // Successful extraction via result.output (constrained decoding) + // --------------------------------------------------------------------------- + + it('returns extracted insights from result.output when available', async () => { + const result = await extractSessionInsights(baseConfig()); + + expect(result.subtask_id).toBe('sub-001'); + expect(result.session_num).toBe(1); + expect(result.success).toBe(true); + expect(result.changed_files).toEqual(['src/auth.ts']); + expect(result.file_insights).toHaveLength(1); + expect(result.file_insights[0].file).toBe('src/app.ts'); + expect(result.patterns_discovered).toContain('Singleton pattern used'); + expect(result.gotchas_discovered).toContain('Must call init() before use'); + expect(result.recommendations).toContain('Add unit tests for singleton'); + }); + + it('populates approach_outcome from result.output', async () => { + const result = await extractSessionInsights(baseConfig()); + + expect(result.approach_outcome.success).toBe(true); + expect(result.approach_outcome.approach_used).toBe('Direct refactor'); + expect(result.approach_outcome.why_it_worked).toBe('Simplified the module'); + expect(result.approach_outcome.why_it_failed).toBeNull(); + }); + + // --------------------------------------------------------------------------- + // Fallback to parseLLMJson when result.output is absent + // --------------------------------------------------------------------------- + + it('falls back to parseLLMJson when result.output is null/undefined', async () => { + mockGenerateText.mockResolvedValue({ + output: null, + text: JSON.stringify({ + file_insights: [{ file: 'src/login.ts', insight: 'Heavy coupling' }], + patterns_discovered: ['MVC'], + gotchas_discovered: [], + approach_outcome: { + success: false, + approach_used: 'monkey-patch', + why_it_worked: null, + why_it_failed: 'Too hacky', + alternatives_tried: [], + }, + recommendations: [], + }), + }); + + const parsedData = { + file_insights: [{ file: 'src/login.ts', insight: 'Heavy coupling' }], + patterns_discovered: ['MVC'], + gotchas_discovered: [], + approach_outcome: { + success: false, + approach_used: 'monkey-patch', + why_it_worked: null, + why_it_failed: 'Too hacky', + alternatives_tried: [], + }, + recommendations: [], + }; + + vi.mocked(parseLLMJson).mockReturnValueOnce(parsedData as unknown as ReturnType<typeof parseLLMJson>); + + const result = await extractSessionInsights(baseConfig({ success: false })); + + expect(result.file_insights[0].file).toBe('src/login.ts'); + expect(result.patterns_discovered).toContain('MVC'); + expect(result.approach_outcome.why_it_failed).toBe('Too hacky'); + }); + + // --------------------------------------------------------------------------- + // Generic fallback when both paths fail + // --------------------------------------------------------------------------- + + it('returns generic insights when result.output is null and parseLLMJson returns null', async () => { + mockGenerateText.mockResolvedValue({ output: null, text: 'not valid json' }); + vi.mocked(parseLLMJson).mockReturnValueOnce(null); + + const result = await extractSessionInsights(baseConfig({ subtaskId: 'sub-fallback', success: false })); + + expect(result.subtask_id).toBe('sub-fallback'); + expect(result.success).toBe(false); + expect(result.file_insights).toEqual([]); + expect(result.patterns_discovered).toEqual([]); + expect(result.gotchas_discovered).toEqual([]); + expect(result.recommendations).toEqual([]); + expect(result.approach_outcome.approach_used).toContain('sub-fallback'); + }); + + it('returns generic insights when generateText throws', async () => { + mockGenerateText.mockRejectedValue(new Error('API unavailable')); + + const result = await extractSessionInsights(baseConfig({ subtaskId: 'sub-error', success: true })); + + expect(result.subtask_id).toBe('sub-error'); + expect(result.success).toBe(true); + expect(result.file_insights).toEqual([]); + }); + + it('returns generic insights when createSimpleClient throws', async () => { + mockCreateSimpleClient.mockRejectedValue(new Error('No credentials')); + + const result = await extractSessionInsights(baseConfig()); + + expect(result.subtask_id).toBe('sub-001'); + expect(result.file_insights).toEqual([]); + }); + + // --------------------------------------------------------------------------- + // Never throws + // --------------------------------------------------------------------------- + + it('never throws — always returns a valid InsightResult', async () => { + mockGenerateText.mockRejectedValue(new Error('catastrophic failure')); + + await expect(extractSessionInsights(baseConfig())).resolves.toBeDefined(); + }); + + // --------------------------------------------------------------------------- + // Client configuration + // --------------------------------------------------------------------------- + + it('uses haiku model and low thinking level by default', async () => { + await extractSessionInsights(baseConfig()); + + const clientArgs = mockCreateSimpleClient.mock.calls[0][0]; + expect(clientArgs.modelShorthand).toBe('haiku'); + expect(clientArgs.thinkingLevel).toBe('low'); + }); + + it('accepts custom modelShorthand and thinkingLevel', async () => { + await extractSessionInsights( + baseConfig({ modelShorthand: 'sonnet', thinkingLevel: 'medium' }), + ); + + const clientArgs = mockCreateSimpleClient.mock.calls[0][0]; + expect(clientArgs.modelShorthand).toBe('sonnet'); + expect(clientArgs.thinkingLevel).toBe('medium'); + }); + + // --------------------------------------------------------------------------- + // Prompt content validation + // --------------------------------------------------------------------------- + + it('includes subtaskId and description in the prompt', async () => { + await extractSessionInsights( + baseConfig({ + subtaskId: 'my-task-42', + subtaskDescription: 'Fix login regression', + }), + ); + + const callArgs = mockGenerateText.mock.calls[0][0]; + expect(callArgs.prompt).toContain('my-task-42'); + expect(callArgs.prompt).toContain('Fix login regression'); + }); + + it('truncates diff when it exceeds 15000 chars', async () => { + const longDiff = '+' + 'a'.repeat(20_000); + + await extractSessionInsights(baseConfig({ diff: longDiff })); + + const callArgs = mockGenerateText.mock.calls[0][0]; + const prompt = callArgs.prompt as string; + // The prompt must mention truncation and not contain all 20k chars of diff + expect(prompt).toContain('truncated'); + }); + + it('includes changed files in the prompt', async () => { + await extractSessionInsights( + baseConfig({ changedFiles: ['src/login.ts', 'src/session.ts'] }), + ); + + const callArgs = mockGenerateText.mock.calls[0][0]; + expect(callArgs.prompt).toContain('src/login.ts'); + }); + + it('includes attempt history in the prompt when provided', async () => { + await extractSessionInsights( + baseConfig({ + attemptHistory: [ + { success: false, approach: 'patch method', error: 'type mismatch' }, + { success: true, approach: 'full rewrite' }, + ], + }), + ); + + const callArgs = mockGenerateText.mock.calls[0][0]; + expect(callArgs.prompt).toContain('patch method'); + expect(callArgs.prompt).toContain('full rewrite'); + }); + + it('passes output schema configuration to generateText', async () => { + await extractSessionInsights(baseConfig()); + + const callArgs = mockGenerateText.mock.calls[0][0]; + // The output key should be set (from Output.object()) + expect(callArgs).toHaveProperty('output'); + }); +}); diff --git a/apps/desktop/src/main/ai/runners/__tests__/insights.test.ts b/apps/desktop/src/main/ai/runners/__tests__/insights.test.ts new file mode 100644 index 00000000..4f6c0d09 --- /dev/null +++ b/apps/desktop/src/main/ai/runners/__tests__/insights.test.ts @@ -0,0 +1,382 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ============================================================================= +// Mocks — must be declared before any imports that use them +// ============================================================================= + +const mockStreamText = vi.fn(); + +vi.mock('ai', () => ({ + streamText: (...args: unknown[]) => mockStreamText(...args), + stepCountIs: (n: number) => ({ type: 'stepCount', count: n }), +})); + +const mockCreateSimpleClient = vi.fn(); + +vi.mock('../../client/factory', () => ({ + createSimpleClient: (...args: unknown[]) => mockCreateSimpleClient(...args), +})); + +// Filesystem mocks — project context files are absent by default +const mockExistsSync = vi.fn().mockReturnValue(false); +const mockReadFileSync = vi.fn(); +const mockReaddirSync = vi.fn().mockReturnValue([]); + +vi.mock('node:fs', () => ({ + existsSync: (...args: unknown[]) => mockExistsSync(...args), + readFileSync: (...args: unknown[]) => mockReadFileSync(...args), + readdirSync: (...args: unknown[]) => mockReaddirSync(...args), +})); + +// Mock tool registry +vi.mock('../../tools/build-registry', () => ({ + buildToolRegistry: () => ({ + getToolsForAgent: vi.fn().mockReturnValue({}), + }), +})); + +// json-repair is used for safeParseJson in the insights runner +vi.mock('../../../utils/json-repair', () => ({ + safeParseJson: (text: string) => { + try { + return JSON.parse(text); + } catch { + return null; + } + }, +})); + +// parseLLMJson is used for task suggestion extraction +vi.mock('../../schema/structured-output', () => ({ + parseLLMJson: vi.fn().mockReturnValue(null), +})); + +vi.mock('../../schema/insight-extractor', () => ({ + TaskSuggestionSchema: {}, +})); + +// ============================================================================= +// Import after mocking +// ============================================================================= + +import { runInsightsQuery } from '../insights'; +import type { InsightsConfig, InsightsStreamEvent } from '../insights'; +import { parseLLMJson } from '../../schema/structured-output'; + +// ============================================================================= +// Helpers +// ============================================================================= + +const fakeModel = { modelId: 'claude-sonnet-test' }; + +function makeMockClient(systemPrompt = 'You are an AI assistant.') { + return { + model: fakeModel, + systemPrompt, + tools: {}, + maxSteps: 30, + }; +} + +function makeStream(parts: Array<Record<string, unknown>>) { + return { + fullStream: (async function* () { + for (const part of parts) { + yield part; + } + })(), + }; +} + +function baseConfig(overrides: Partial<InsightsConfig> = {}): InsightsConfig { + return { + projectDir: '/project', + message: 'How does authentication work?', + ...overrides, + }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('runInsightsQuery', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreateSimpleClient.mockResolvedValue(makeMockClient()); + mockExistsSync.mockReturnValue(false); + mockReaddirSync.mockReturnValue([]); + vi.mocked(parseLLMJson).mockReturnValue(null); + }); + + // --------------------------------------------------------------------------- + // Successful run — no streaming events needed from caller + // --------------------------------------------------------------------------- + + it('returns response text accumulated from stream', async () => { + mockStreamText.mockReturnValue( + makeStream([ + { type: 'text-delta', text: 'Authentication uses JWT tokens.' }, + { type: 'text-delta', text: ' Tokens expire after 1 hour.' }, + ]), + ); + + const result = await runInsightsQuery(baseConfig()); + + expect(result.text).toBe('Authentication uses JWT tokens. Tokens expire after 1 hour.'); + expect(result.taskSuggestion).toBeNull(); + expect(result.toolCalls).toEqual([]); + }); + + it('returns empty text and no task suggestion when stream is empty', async () => { + mockStreamText.mockReturnValue(makeStream([])); + + const result = await runInsightsQuery(baseConfig()); + + expect(result.text).toBe(''); + expect(result.taskSuggestion).toBeNull(); + }); + + // --------------------------------------------------------------------------- + // Task suggestion extraction + // --------------------------------------------------------------------------- + + it('extracts task suggestion from response text when marker present', async () => { + const suggestion = { + title: 'Add rate limiting', + description: 'Implement per-user rate limiting on auth endpoints', + metadata: { category: 'security', complexity: 'medium', impact: 'high' }, + }; + + mockStreamText.mockReturnValue( + makeStream([ + { + type: 'text-delta', + text: `Here is my suggestion.\n__TASK_SUGGESTION__:${JSON.stringify(suggestion)}\n`, + }, + ]), + ); + + vi.mocked(parseLLMJson).mockReturnValueOnce(suggestion as unknown as ReturnType<typeof parseLLMJson>); + + const result = await runInsightsQuery(baseConfig()); + + expect(result.taskSuggestion).not.toBeNull(); + expect(result.taskSuggestion?.title).toBe('Add rate limiting'); + expect(result.taskSuggestion?.metadata.category).toBe('security'); + }); + + it('returns null taskSuggestion when no marker in response', async () => { + mockStreamText.mockReturnValue( + makeStream([{ type: 'text-delta', text: 'No suggestions here.' }]), + ); + + const result = await runInsightsQuery(baseConfig()); + + expect(result.taskSuggestion).toBeNull(); + }); + + // --------------------------------------------------------------------------- + // Tool call tracking + // --------------------------------------------------------------------------- + + it('tracks tool calls in result.toolCalls', async () => { + mockStreamText.mockReturnValue( + makeStream([ + { type: 'tool-call', toolName: 'Read', toolCallId: 'c1', input: { file_path: 'src/auth.ts' } }, + { type: 'tool-result', toolCallId: 'c1', toolName: 'Read', output: 'file content' }, + { type: 'tool-call', toolName: 'Glob', toolCallId: 'c2', input: { pattern: '**/*.ts' } }, + { type: 'tool-result', toolCallId: 'c2', toolName: 'Glob', output: 'src/auth.ts' }, + ]), + ); + + const result = await runInsightsQuery(baseConfig()); + + expect(result.toolCalls).toHaveLength(2); + expect(result.toolCalls[0].name).toBe('Read'); + expect(result.toolCalls[1].name).toBe('Glob'); + }); + + it('extracts file_path from Read tool call input', async () => { + mockStreamText.mockReturnValue( + makeStream([ + { + type: 'tool-call', + toolName: 'Read', + toolCallId: 'c1', + input: { file_path: 'src/auth.ts' }, + }, + ]), + ); + + const result = await runInsightsQuery(baseConfig()); + + expect(result.toolCalls[0].input).toBe('src/auth.ts'); + }); + + it('extracts pattern from Grep/Glob tool call input', async () => { + mockStreamText.mockReturnValue( + makeStream([ + { + type: 'tool-call', + toolName: 'Grep', + toolCallId: 'c1', + input: { pattern: 'useAuth' }, + }, + ]), + ); + + const result = await runInsightsQuery(baseConfig()); + + expect(result.toolCalls[0].input).toBe('pattern: useAuth'); + }); + + // --------------------------------------------------------------------------- + // Stream callbacks + // --------------------------------------------------------------------------- + + it('forwards text-delta events to onStream callback', async () => { + mockStreamText.mockReturnValue( + makeStream([ + { type: 'text-delta', text: 'chunk1' }, + { type: 'text-delta', text: 'chunk2' }, + ]), + ); + + const events: InsightsStreamEvent[] = []; + await runInsightsQuery(baseConfig(), (e) => events.push(e)); + + const textEvents = events.filter((e) => e.type === 'text-delta'); + expect(textEvents).toHaveLength(2); + }); + + it('forwards tool-start events for tool-call stream parts', async () => { + mockStreamText.mockReturnValue( + makeStream([ + { type: 'tool-call', toolName: 'Grep', toolCallId: 'c1', input: { pattern: 'login' } }, + ]), + ); + + const events: InsightsStreamEvent[] = []; + await runInsightsQuery(baseConfig(), (e) => events.push(e)); + + const toolStartEvents = events.filter((e) => e.type === 'tool-start'); + expect(toolStartEvents).toHaveLength(1); + expect((toolStartEvents[0] as { type: 'tool-start'; name: string }).name).toBe('Grep'); + }); + + it('forwards tool-end events for tool-result stream parts', async () => { + mockStreamText.mockReturnValue( + makeStream([ + { type: 'tool-result', toolCallId: 'c1', toolName: 'Read', output: 'content' }, + ]), + ); + + const events: InsightsStreamEvent[] = []; + await runInsightsQuery(baseConfig(), (e) => events.push(e)); + + const toolEndEvents = events.filter((e) => e.type === 'tool-end'); + expect(toolEndEvents).toHaveLength(1); + }); + + it('forwards error events for error stream parts', async () => { + mockStreamText.mockReturnValue( + makeStream([{ type: 'error', error: new Error('tool failed') }]), + ); + + const events: InsightsStreamEvent[] = []; + await runInsightsQuery(baseConfig(), (e) => events.push(e)); + + const errorEvents = events.filter((e) => e.type === 'error'); + expect(errorEvents).toHaveLength(1); + expect((errorEvents[0] as { type: 'error'; error: string }).error).toBe('tool failed'); + }); + + // --------------------------------------------------------------------------- + // Error propagation + // --------------------------------------------------------------------------- + + it('rethrows when streamText iteration throws', async () => { + mockStreamText.mockReturnValue({ + // biome-ignore lint/correctness/useYield: intentionally throwing before yield to test error path + fullStream: (async function* () { + throw new Error('API timeout'); + })(), + }); + + await expect(runInsightsQuery(baseConfig())).rejects.toThrow('API timeout'); + }); + + it('emits error event to callback before rethrowing', async () => { + mockStreamText.mockReturnValue({ + // biome-ignore lint/correctness/useYield: intentionally throwing before yield to test error path + fullStream: (async function* () { + throw new Error('rate limited'); + })(), + }); + + const events: InsightsStreamEvent[] = []; + await expect(runInsightsQuery(baseConfig(), (e) => events.push(e))).rejects.toThrow( + 'rate limited', + ); + + expect(events.some((e) => e.type === 'error')).toBe(true); + }); + + // --------------------------------------------------------------------------- + // Client configuration + // --------------------------------------------------------------------------- + + it('uses sonnet model and medium thinking level by default', async () => { + mockStreamText.mockReturnValue(makeStream([])); + + await runInsightsQuery(baseConfig()); + + const clientArgs = mockCreateSimpleClient.mock.calls[0][0]; + expect(clientArgs.modelShorthand).toBe('sonnet'); + expect(clientArgs.thinkingLevel).toBe('medium'); + }); + + it('accepts custom modelShorthand and thinkingLevel', async () => { + mockStreamText.mockReturnValue(makeStream([])); + + await runInsightsQuery(baseConfig({ modelShorthand: 'haiku', thinkingLevel: 'low' })); + + const clientArgs = mockCreateSimpleClient.mock.calls[0][0]; + expect(clientArgs.modelShorthand).toBe('haiku'); + expect(clientArgs.thinkingLevel).toBe('low'); + }); + + // --------------------------------------------------------------------------- + // History handling + // --------------------------------------------------------------------------- + + it('includes conversation history in the prompt when provided', async () => { + mockStreamText.mockReturnValue(makeStream([])); + + await runInsightsQuery( + baseConfig({ + message: 'What about refresh tokens?', + history: [ + { role: 'user', content: 'How does auth work?' }, + { role: 'assistant', content: 'It uses JWT.' }, + ], + }), + ); + + const callArgs = mockStreamText.mock.calls[0][0]; + const prompt = callArgs.prompt as string; + expect(prompt).toContain('How does auth work?'); + expect(prompt).toContain('It uses JWT.'); + expect(prompt).toContain('What about refresh tokens?'); + }); + + it('uses message directly as prompt when history is empty', async () => { + mockStreamText.mockReturnValue(makeStream([])); + + await runInsightsQuery(baseConfig({ message: 'What is the entry point?' })); + + const callArgs = mockStreamText.mock.calls[0][0]; + expect(callArgs.prompt).toBe('What is the entry point?'); + }); +}); diff --git a/apps/desktop/src/main/ai/runners/__tests__/merge-resolver.test.ts b/apps/desktop/src/main/ai/runners/__tests__/merge-resolver.test.ts new file mode 100644 index 00000000..40d43052 --- /dev/null +++ b/apps/desktop/src/main/ai/runners/__tests__/merge-resolver.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ============================================================================= +// Mocks — must be declared before any imports that use them +// ============================================================================= + +const mockGenerateText = vi.fn(); + +vi.mock('ai', () => ({ + generateText: (...args: unknown[]) => mockGenerateText(...args), +})); + +const mockCreateSimpleClient = vi.fn(); + +vi.mock('../../client/factory', () => ({ + createSimpleClient: (...args: unknown[]) => mockCreateSimpleClient(...args), +})); + +// ============================================================================= +// Import after mocking +// ============================================================================= + +import { resolveMergeConflict, createMergeResolverFn } from '../merge-resolver'; +import type { MergeResolverConfig } from '../merge-resolver'; + +// ============================================================================= +// Helpers +// ============================================================================= + +const fakeModel = { modelId: 'claude-haiku-test' }; + +function makeMockClient(systemPrompt = 'Resolve merge conflicts.') { + return { model: fakeModel, systemPrompt }; +} + +function baseConfig(overrides: Partial<MergeResolverConfig> = {}): MergeResolverConfig { + return { + systemPrompt: 'You are a merge conflict resolver.', + userPrompt: '<<<\nHEAD version\n===\nIncoming version\n>>>', + ...overrides, + }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('resolveMergeConflict', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreateSimpleClient.mockResolvedValue(makeMockClient()); + }); + + // --------------------------------------------------------------------------- + // Successful resolution + // --------------------------------------------------------------------------- + + it('returns success with trimmed resolved text', async () => { + mockGenerateText.mockResolvedValue({ text: ' Resolved: use incoming version. ' }); + + const result = await resolveMergeConflict(baseConfig()); + + expect(result.success).toBe(true); + expect(result.text).toBe('Resolved: use incoming version.'); + expect(result.error).toBeUndefined(); + }); + + it('passes model and systemPrompt from client to generateText', async () => { + mockGenerateText.mockResolvedValue({ text: 'merged code here' }); + + await resolveMergeConflict(baseConfig()); + + expect(mockGenerateText).toHaveBeenCalledOnce(); + const callArgs = mockGenerateText.mock.calls[0][0]; + expect(callArgs.model).toBe(fakeModel); + expect(callArgs.system).toBe('Resolve merge conflicts.'); + }); + + it('passes userPrompt as the prompt parameter to generateText', async () => { + mockGenerateText.mockResolvedValue({ text: 'resolved' }); + + const conflict = '<<<\nmy change\n===\ntheir change\n>>>'; + await resolveMergeConflict(baseConfig({ userPrompt: conflict })); + + const callArgs = mockGenerateText.mock.calls[0][0]; + expect(callArgs.prompt).toBe(conflict); + }); + + it('passes systemPrompt config to createSimpleClient', async () => { + mockGenerateText.mockResolvedValue({ text: 'resolved' }); + + const customSystem = 'Custom system prompt.'; + await resolveMergeConflict(baseConfig({ systemPrompt: customSystem })); + + const clientArgs = mockCreateSimpleClient.mock.calls[0][0]; + expect(clientArgs.systemPrompt).toBe(customSystem); + }); + + // --------------------------------------------------------------------------- + // Default model / thinking level + // --------------------------------------------------------------------------- + + it('uses haiku model and low thinking level by default', async () => { + mockGenerateText.mockResolvedValue({ text: 'resolved' }); + + await resolveMergeConflict(baseConfig()); + + const clientArgs = mockCreateSimpleClient.mock.calls[0][0]; + expect(clientArgs.modelShorthand).toBe('haiku'); + expect(clientArgs.thinkingLevel).toBe('low'); + }); + + it('accepts custom modelShorthand and thinkingLevel', async () => { + mockGenerateText.mockResolvedValue({ text: 'resolved' }); + + await resolveMergeConflict( + baseConfig({ modelShorthand: 'sonnet', thinkingLevel: 'medium' }), + ); + + const clientArgs = mockCreateSimpleClient.mock.calls[0][0]; + expect(clientArgs.modelShorthand).toBe('sonnet'); + expect(clientArgs.thinkingLevel).toBe('medium'); + }); + + // --------------------------------------------------------------------------- + // Empty response handling + // --------------------------------------------------------------------------- + + it('returns failure when LLM returns empty text', async () => { + mockGenerateText.mockResolvedValue({ text: ' ' }); + + const result = await resolveMergeConflict(baseConfig()); + + expect(result.success).toBe(false); + expect(result.text).toBe(''); + expect(result.error).toBe('Empty response from AI'); + }); + + // --------------------------------------------------------------------------- + // Error handling + // --------------------------------------------------------------------------- + + it('returns failure with error message when generateText throws Error', async () => { + mockGenerateText.mockRejectedValue(new Error('API rate limit')); + + const result = await resolveMergeConflict(baseConfig()); + + expect(result.success).toBe(false); + expect(result.text).toBe(''); + expect(result.error).toBe('API rate limit'); + }); + + it('returns failure with string coercion when non-Error is thrown', async () => { + mockGenerateText.mockRejectedValue('connection refused'); + + const result = await resolveMergeConflict(baseConfig()); + + expect(result.success).toBe(false); + expect(result.error).toBe('connection refused'); + }); + + it('returns failure when createSimpleClient throws', async () => { + mockCreateSimpleClient.mockRejectedValue(new Error('No auth token')); + + const result = await resolveMergeConflict(baseConfig()); + + expect(result.success).toBe(false); + expect(result.error).toBe('No auth token'); + }); +}); + +// ============================================================================= +// createMergeResolverFn +// ============================================================================= + +describe('createMergeResolverFn', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreateSimpleClient.mockResolvedValue(makeMockClient()); + }); + + it('returns an async function', () => { + const fn = createMergeResolverFn(); + expect(typeof fn).toBe('function'); + }); + + it('returned function resolves to the resolved text on success', async () => { + mockGenerateText.mockResolvedValue({ text: 'merged content' }); + + const fn = createMergeResolverFn(); + const result = await fn('system context', 'conflict block'); + + expect(result).toBe('merged content'); + }); + + it('returned function resolves to empty string when LLM returns empty', async () => { + mockGenerateText.mockResolvedValue({ text: ' ' }); + + const fn = createMergeResolverFn(); + const result = await fn('system', 'conflict'); + + expect(result).toBe(''); + }); + + it('returned function resolves to empty string on error (does not throw)', async () => { + mockGenerateText.mockRejectedValue(new Error('timeout')); + + const fn = createMergeResolverFn(); + const result = await fn('system', 'conflict'); + + expect(result).toBe(''); + }); + + it('uses provided modelShorthand and thinkingLevel', async () => { + mockGenerateText.mockResolvedValue({ text: 'resolved' }); + + const fn = createMergeResolverFn('sonnet', 'medium'); + await fn('sys', 'user'); + + const clientArgs = mockCreateSimpleClient.mock.calls[0][0]; + expect(clientArgs.modelShorthand).toBe('sonnet'); + expect(clientArgs.thinkingLevel).toBe('medium'); + }); + + it('defaults to haiku and low when no arguments given', async () => { + mockGenerateText.mockResolvedValue({ text: 'resolved' }); + + const fn = createMergeResolverFn(); + await fn('sys', 'user'); + + const clientArgs = mockCreateSimpleClient.mock.calls[0][0]; + expect(clientArgs.modelShorthand).toBe('haiku'); + expect(clientArgs.thinkingLevel).toBe('low'); + }); + + it('passes system and user arguments as systemPrompt and userPrompt', async () => { + mockGenerateText.mockResolvedValue({ text: 'resolved' }); + + const fn = createMergeResolverFn(); + await fn('the system prompt', 'the conflict text'); + + const clientArgs = mockCreateSimpleClient.mock.calls[0][0]; + expect(clientArgs.systemPrompt).toBe('the system prompt'); + const generateArgs = mockGenerateText.mock.calls[0][0]; + expect(generateArgs.prompt).toBe('the conflict text'); + }); +}); diff --git a/apps/desktop/src/main/ai/runners/__tests__/roadmap.test.ts b/apps/desktop/src/main/ai/runners/__tests__/roadmap.test.ts new file mode 100644 index 00000000..16721617 --- /dev/null +++ b/apps/desktop/src/main/ai/runners/__tests__/roadmap.test.ts @@ -0,0 +1,420 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ============================================================================= +// Mocks — must be declared before any imports that use them +// ============================================================================= + +const mockStreamText = vi.fn(); + +vi.mock('ai', () => ({ + streamText: (...args: unknown[]) => mockStreamText(...args), + stepCountIs: (n: number) => ({ type: 'stepCount', count: n }), +})); + +const mockCreateSimpleClient = vi.fn(); + +vi.mock('../../client/factory', () => ({ + createSimpleClient: (...args: unknown[]) => mockCreateSimpleClient(...args), +})); + +// Filesystem mocks +const mockExistsSync = vi.fn(); +const mockReadFileSync = vi.fn(); +const mockWriteFileSync = vi.fn(); +const mockMkdirSync = vi.fn(); +const mockRenameSync = vi.fn(); + +vi.mock('node:fs', () => ({ + existsSync: (...args: unknown[]) => mockExistsSync(...args), + readFileSync: (...args: unknown[]) => mockReadFileSync(...args), + writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args), + mkdirSync: (...args: unknown[]) => mockMkdirSync(...args), + renameSync: (...args: unknown[]) => mockRenameSync(...args), +})); + +// Tool registry mock +vi.mock('../../tools/build-registry', () => ({ + buildToolRegistry: () => ({ + getToolsForAgent: vi.fn().mockReturnValue({}), + }), +})); + +// json-repair used for safeParseJson +vi.mock('../../../utils/json-repair', () => ({ + safeParseJson: (text: string) => { + try { + return JSON.parse(text); + } catch { + return null; + } + }, +})); + +// tryLoadPrompt — return null so inline prompts are used +vi.mock('../../prompts/prompt-loader', () => ({ + tryLoadPrompt: vi.fn().mockReturnValue(null), +})); + +// ============================================================================= +// Import after mocking +// ============================================================================= + +import { runRoadmapGeneration } from '../roadmap'; +import type { RoadmapConfig, RoadmapStreamEvent } from '../roadmap'; + +// ============================================================================= +// Helpers +// ============================================================================= + +const fakeModel = { modelId: 'claude-sonnet-test' }; + +function makeMockClient() { + return { + model: fakeModel, + systemPrompt: '', + tools: {}, + maxSteps: 30, + }; +} + +function makeStream(parts: Array<Record<string, unknown>>) { + return { + fullStream: (async function* () { + for (const part of parts) { + yield part; + } + })(), + }; +} + +/** Valid discovery JSON that passes schema validation */ +const VALID_DISCOVERY_JSON = JSON.stringify({ + project_name: 'TestProject', + target_audience: 'Developers', + product_vision: 'Make coding easier', + key_features: ['Auth', 'Dashboard'], + technical_stack: { language: 'TypeScript' }, + constraints: [], +}); + +/** Valid roadmap JSON that passes schema validation (>=3 features, all required keys) */ +const VALID_ROADMAP_JSON = JSON.stringify({ + vision: 'Automate everything', + target_audience: { primary: 'Developers', secondary: 'QA' }, + phases: [{ id: 'p1', name: 'MVP' }], + features: [ + { + id: 'f1', title: 'Feature A', description: 'Desc A', priority: 'high', + complexity: 'medium', impact: 'high', phase_id: 'p1', status: 'planned', + acceptance_criteria: [], user_stories: [], + }, + { + id: 'f2', title: 'Feature B', description: 'Desc B', priority: 'medium', + complexity: 'low', impact: 'medium', phase_id: 'p1', status: 'planned', + acceptance_criteria: [], user_stories: [], + }, + { + id: 'f3', title: 'Feature C', description: 'Desc C', priority: 'low', + complexity: 'high', impact: 'low', phase_id: 'p1', status: 'planned', + acceptance_criteria: [], user_stories: [], + }, + ], +}); + +function baseConfig(overrides: Partial<RoadmapConfig> = {}): RoadmapConfig { + return { + projectDir: '/project', + outputDir: '/project/.auto-claude/roadmap', + ...overrides, + }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('runRoadmapGeneration', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreateSimpleClient.mockResolvedValue(makeMockClient()); + // Output dir exists by default (created by mkdirSync is a no-op) + mockExistsSync.mockReturnValue(false); + mockMkdirSync.mockReturnValue(undefined); + mockStreamText.mockReturnValue(makeStream([])); + }); + + // --------------------------------------------------------------------------- + // Successful full pipeline + // --------------------------------------------------------------------------- + + it('returns success with roadmapPath when both phases succeed', async () => { + // existsSync: outputDir does not exist initially; discovery file created after phase 1 + let discoveryCreated = false; + + mockExistsSync.mockImplementation((p: string) => { + if (p.endsWith('roadmap')) return true; // outputDir exists + if (p.endsWith('roadmap_discovery.json') && discoveryCreated) return true; + if (p.endsWith('roadmap.json') && discoveryCreated) return false; // not yet + return false; + }); + + // streamText yields nothing — validation happens from file reads + mockStreamText.mockImplementation(() => { + discoveryCreated = true; // simulate file being written during stream + return makeStream([]); + }); + + // readFileSync returns valid JSON for each file + mockReadFileSync.mockImplementation((p: string) => { + if (p.endsWith('roadmap_discovery.json')) return VALID_DISCOVERY_JSON; + if (p.endsWith('roadmap.json')) return VALID_ROADMAP_JSON; + return '{}'; + }); + + // After agent runs, both files exist + mockExistsSync.mockImplementation((p: string) => { + if (p.endsWith('roadmap_discovery.json')) return true; + if (p.endsWith('roadmap.json')) return true; + return true; // outputDir, etc. + }); + + const result = await runRoadmapGeneration(baseConfig()); + + expect(result.success).toBe(true); + expect(result.roadmapPath).toContain('roadmap.json'); + expect(result.phases).toHaveLength(2); + expect(result.phases[0].phase).toBe('discovery'); + expect(result.phases[1].phase).toBe('features'); + }); + + // --------------------------------------------------------------------------- + // Discovery phase failure + // --------------------------------------------------------------------------- + + it('returns failure when discovery phase fails after all retries', async () => { + // Discovery file is never created + mockExistsSync.mockImplementation((p: string) => { + if (p.endsWith('roadmap')) return true; // outputDir exists + return false; // discovery file never appears + }); + + mockStreamText.mockReturnValue(makeStream([])); + + const result = await runRoadmapGeneration(baseConfig()); + + expect(result.success).toBe(false); + expect(result.error).toContain('Discovery failed'); + expect(result.phases).toHaveLength(1); + expect(result.phases[0].phase).toBe('discovery'); + expect(result.phases[0].success).toBe(false); + }); + + it('does not run features phase when discovery fails', async () => { + mockExistsSync.mockReturnValue(false); + mockStreamText.mockReturnValue(makeStream([])); + + const result = await runRoadmapGeneration(baseConfig()); + + // Only 1 phase in result — features was never attempted + expect(result.phases).toHaveLength(1); + }); + + // --------------------------------------------------------------------------- + // Features phase failure + // --------------------------------------------------------------------------- + + it('returns failure when features phase fails (no roadmap.json created)', async () => { + mockExistsSync.mockImplementation((p: string) => { + if (p.endsWith('roadmap')) return true; + if (p.endsWith('roadmap_discovery.json')) return true; // discovery succeeded + if (p.endsWith('project_index.json')) return false; + return false; // roadmap.json never created + }); + + mockReadFileSync.mockImplementation((p: string) => { + if (p.endsWith('roadmap_discovery.json')) return VALID_DISCOVERY_JSON; + return '{}'; + }); + + mockStreamText.mockReturnValue(makeStream([])); + + const result = await runRoadmapGeneration(baseConfig()); + + expect(result.success).toBe(false); + expect(result.error).toContain('Feature generation failed'); + expect(result.phases).toHaveLength(2); + expect(result.phases[1].phase).toBe('features'); + expect(result.phases[1].success).toBe(false); + }); + + // --------------------------------------------------------------------------- + // Cache (refresh=false) — skip phases when files already exist + // --------------------------------------------------------------------------- + + it('skips discovery phase when discovery file already exists and refresh=false', async () => { + mockExistsSync.mockImplementation((p: string) => { + if (p.endsWith('roadmap')) return true; + if (p.endsWith('roadmap_discovery.json')) return true; // already exists + if (p.endsWith('roadmap.json')) return true; // also exists + return false; + }); + + const result = await runRoadmapGeneration(baseConfig({ refresh: false })); + + // streamText should not have been called since both files exist + expect(mockStreamText).not.toHaveBeenCalled(); + expect(result.success).toBe(true); + }); + + // --------------------------------------------------------------------------- + // Output directory creation + // --------------------------------------------------------------------------- + + it('creates output directory when it does not exist', async () => { + mockExistsSync.mockImplementation((p: string) => { + if (p.endsWith('roadmap') && !p.includes('.json')) return false; // dir does not exist + return false; + }); + mockStreamText.mockReturnValue(makeStream([])); + + await runRoadmapGeneration(baseConfig({ outputDir: '/project/.auto-claude/roadmap' })); + + expect(mockMkdirSync).toHaveBeenCalledWith( + expect.stringContaining('roadmap'), + expect.objectContaining({ recursive: true }), + ); + }); + + // --------------------------------------------------------------------------- + // Streaming events + // --------------------------------------------------------------------------- + + it('emits phase-start and phase-complete events for both phases', async () => { + // Make discovery succeed via cached file + mockExistsSync.mockImplementation((p: string) => { + if (p.endsWith('roadmap')) return true; + if (p.endsWith('roadmap_discovery.json')) return true; + if (p.endsWith('roadmap.json')) return true; + return false; + }); + + const events: RoadmapStreamEvent[] = []; + await runRoadmapGeneration(baseConfig({ refresh: false }), (e) => events.push(e)); + + const phaseStartEvents = events.filter((e) => e.type === 'phase-start'); + const phaseCompleteEvents = events.filter((e) => e.type === 'phase-complete'); + + expect(phaseStartEvents).toHaveLength(2); + expect(phaseCompleteEvents).toHaveLength(2); + expect((phaseStartEvents[0] as { type: string; phase: string }).phase).toBe('discovery'); + expect((phaseStartEvents[1] as { type: string; phase: string }).phase).toBe('features'); + }); + + it('forwards text-delta events from stream to callback', async () => { + mockExistsSync.mockImplementation((p: string) => { + if (p.endsWith('roadmap_discovery.json')) return true; + if (p.endsWith('roadmap.json')) return true; + return true; + }); + + const events: RoadmapStreamEvent[] = []; + await runRoadmapGeneration(baseConfig({ refresh: false }), (e) => events.push(e)); + + // Since files exist and refresh=false, streamText is never called and no text-delta fires + // This confirms the caching path works correctly + const textEvents = events.filter((e) => e.type === 'text-delta'); + expect(textEvents).toHaveLength(0); + }); + + it('forwards text-delta from active streamText run when discovery must be generated', async () => { + // outputDir exists, but discovery file does not (first attempt) + // After first streamText run, discovery file appears + let callCount = 0; + mockExistsSync.mockImplementation((p: string) => { + if (p.endsWith('roadmap') && !p.includes('.json')) return true; + if (p.endsWith('roadmap_discovery.json')) return callCount > 0; + return false; + }); + + mockReadFileSync.mockReturnValue(VALID_DISCOVERY_JSON); + + mockStreamText.mockImplementation(() => { + callCount++; + return { + fullStream: (async function* () { + yield { type: 'text-delta', text: 'Analyzing project...' }; + })(), + }; + }); + + const events: RoadmapStreamEvent[] = []; + await runRoadmapGeneration(baseConfig(), (e) => events.push(e)); + + const textEvents = events.filter((e) => e.type === 'text-delta'); + expect(textEvents.length).toBeGreaterThan(0); + }); + + // --------------------------------------------------------------------------- + // Client configuration + // --------------------------------------------------------------------------- + + it('uses sonnet and medium thinking level by default', async () => { + mockExistsSync.mockReturnValue(false); + mockStreamText.mockReturnValue(makeStream([])); + + await runRoadmapGeneration(baseConfig()); + + const clientArgs = mockCreateSimpleClient.mock.calls[0][0]; + expect(clientArgs.modelShorthand).toBe('sonnet'); + expect(clientArgs.thinkingLevel).toBe('medium'); + }); + + it('accepts custom modelShorthand and thinkingLevel', async () => { + mockExistsSync.mockReturnValue(false); + mockStreamText.mockReturnValue(makeStream([])); + + await runRoadmapGeneration(baseConfig({ modelShorthand: 'haiku', thinkingLevel: 'low' })); + + const clientArgs = mockCreateSimpleClient.mock.calls[0][0]; + expect(clientArgs.modelShorthand).toBe('haiku'); + expect(clientArgs.thinkingLevel).toBe('low'); + }); + + it('uses default outputDir when not provided', async () => { + mockExistsSync.mockReturnValue(false); + mockStreamText.mockReturnValue(makeStream([])); + + await runRoadmapGeneration({ projectDir: '/my/project' }); + + // mkdirSync should have been called with the default path + expect(mockMkdirSync).toHaveBeenCalledWith( + expect.stringContaining('.auto-claude'), + expect.anything(), + ); + }); + + // --------------------------------------------------------------------------- + // Error handling — streamText throws + // --------------------------------------------------------------------------- + + it('records error in phase when streamText throws during discovery', async () => { + mockExistsSync.mockImplementation((p: string) => { + if (p.endsWith('roadmap') && !p.includes('.json')) return true; + return false; + }); + + mockStreamText.mockImplementation(() => { + return { + // biome-ignore lint/correctness/useYield: intentionally throwing before yield to test error path + fullStream: (async function* () { + throw new Error('network failure'); + })(), + }; + }); + + const result = await runRoadmapGeneration(baseConfig()); + + expect(result.success).toBe(false); + expect(result.phases[0].errors.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/desktop/src/main/ai/tools/builtin/__tests__/bash.test.ts b/apps/desktop/src/main/ai/tools/builtin/__tests__/bash.test.ts new file mode 100644 index 00000000..a0465bd8 --- /dev/null +++ b/apps/desktop/src/main/ai/tools/builtin/__tests__/bash.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { bashTool } from '../bash'; +import type { ToolContext } from '../../types'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockExecFile = vi.fn(); +vi.mock('node:child_process', () => ({ + execFile: (...args: unknown[]) => mockExecFile(...args), +})); + +const mockIsWindows = vi.fn(() => false); +const mockFindExecutable = vi.fn(() => null); +const mockKillProcessGracefully = vi.fn(); + +vi.mock('../../../../platform/index', () => ({ + isWindows: () => mockIsWindows(), + findExecutable: (_name: string, _additionalPaths?: string[]) => mockFindExecutable(), + killProcessGracefully: (_childProcess: unknown, _options?: unknown) => mockKillProcessGracefully(), +})); + +const mockBashSecurityHook = vi.fn(() => ({})); +vi.mock('../../../security/bash-validator', () => ({ + bashSecurityHook: (_input: unknown, _profile?: unknown) => mockBashSecurityHook(), +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const baseContext: ToolContext = { + cwd: '/test/project', + projectDir: '/test/project', + specDir: '/test/specs/001', + securityProfile: { + baseCommands: new Set(), + stackCommands: new Set(), + scriptCommands: new Set(), + customCommands: new Set(), + customScripts: { shellScripts: [] }, + getAllAllowedCommands: () => new Set(), + }, +} as unknown as ToolContext; + +/** + * Set up mockExecFile to invoke the callback with the provided values. + */ +function setupExecFile(stdout: string, stderr: string, exitCode: number) { + mockExecFile.mockImplementation( + (_shell: unknown, _args: unknown, _opts: unknown, callback: (err: Error | null, stdout: string, stderr: string) => void) => { + const err = exitCode !== 0 ? Object.assign(new Error('exit'), { code: exitCode }) : null; + callback(err, stdout, stderr); + return { pid: 1234 }; + }, + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Bash Tool', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsWindows.mockReturnValue(false); + mockBashSecurityHook.mockReturnValue({}); + }); + + it('should have correct metadata', () => { + expect(bashTool.metadata.name).toBe('Bash'); + expect(bashTool.metadata.permission).toBe('requires_approval'); + }); + + it('should return stdout from successful command', async () => { + setupExecFile('hello from bash\n', '', 0); + + const result = await bashTool.config.execute( + { command: 'echo hello from bash' }, + baseContext, + ); + + expect(result).toContain('hello from bash'); + }); + + it('should include stderr in output when present', async () => { + setupExecFile('', 'some warning\n', 0); + + const result = await bashTool.config.execute( + { command: 'cmd-with-stderr' }, + baseContext, + ); + + expect(result).toContain('STDERR:'); + expect(result).toContain('some warning'); + }); + + it('should include exit code in output when non-zero', async () => { + setupExecFile('', '', 1); + + const result = await bashTool.config.execute( + { command: 'failing-command' }, + baseContext, + ); + + expect(result).toContain('Exit code: 1'); + }); + + it('should return (no output) when stdout and stderr are empty and exit code is 0', async () => { + setupExecFile('', '', 0); + + const result = await bashTool.config.execute( + { command: 'silent-command' }, + baseContext, + ); + + expect(result).toBe('(no output)'); + }); + + it('should truncate output exceeding MAX_OUTPUT_LENGTH', async () => { + const longOutput = 'x'.repeat(31_000); + setupExecFile(longOutput, '', 0); + + const result = await bashTool.config.execute( + { command: 'long-output-cmd' }, + baseContext, + ); + + expect(result).toContain('[Output truncated'); + expect(result.length).toBeLessThan(longOutput.length); + }); + + it('should return error message when security hook rejects command', async () => { + mockBashSecurityHook.mockReturnValue({ + hookSpecificOutput: { + permissionDecisionReason: 'command is blocked for safety', + }, + }); + + const result = await bashTool.config.execute( + { command: 'rm -rf /' }, + baseContext, + ); + + expect(result).toContain('Error: Command not allowed'); + expect(result).toContain('command is blocked for safety'); + expect(mockExecFile).not.toHaveBeenCalled(); + }); + + it('should start command in background and return immediately', async () => { + // In background mode the execute call is fire-and-forget, so mockExecFile + // may or may not be called synchronously. The return value is what matters. + mockExecFile.mockImplementation( + (_shell: unknown, _args: unknown, _opts: unknown, _callback: unknown) => { + return { pid: 5678 }; + }, + ); + + const result = await bashTool.config.execute( + { command: 'sleep 100', run_in_background: true }, + baseContext, + ); + + expect(result).toContain('Command started in background'); + expect(result).toContain('sleep 100'); + }); + + it('should pass cwd from context to execFile', async () => { + setupExecFile('output', '', 0); + + await bashTool.config.execute( + { command: 'pwd' }, + baseContext, + ); + + expect(mockExecFile).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ cwd: '/test/project' }), + expect.any(Function), + ); + }); + + it('should cap timeout to MAX_TIMEOUT_MS (600000)', async () => { + setupExecFile('output', '', 0); + + await bashTool.config.execute( + { command: 'cmd', timeout: 9_000_000 }, + baseContext, + ); + + expect(mockExecFile).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ timeout: 600_000 }), + expect.any(Function), + ); + }); + + it('should use /bin/bash as shell on non-Windows', async () => { + mockIsWindows.mockReturnValue(false); + setupExecFile('output', '', 0); + + await bashTool.config.execute( + { command: 'echo hi' }, + baseContext, + ); + + expect(mockExecFile).toHaveBeenCalledWith( + '/bin/bash', + ['-c', 'echo hi'], + expect.any(Object), + expect.any(Function), + ); + }); + + it('should use cmd.exe args (/c) on Windows when bash not found', async () => { + // The Windows branch uses /c rather than -c for cmd.exe. + // We verify the logic by checking that bash uses -c on non-Windows (already tested + // above) and that the findExecutable mock would select the right executable. + // This test validates the cmd.exe ComSpec fallback resolution path. + mockIsWindows.mockReturnValue(true); + mockFindExecutable.mockReturnValue(null); + + const origComSpec = process.env.ComSpec; + process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'; + + setupExecFile('output', '', 0); + + await bashTool.config.execute( + { command: 'dir' }, + baseContext, + ); + + // Verify that on Windows with no bash found, cmd.exe with /c flag is used + const callArgs = mockExecFile.mock.calls[0]; + const shell = callArgs[0] as string; + const args = callArgs[1] as string[]; + + // The shell should be cmd.exe (via ComSpec) and arg should be /c + expect(shell).toBe('C:\\Windows\\System32\\cmd.exe'); + expect(args[0]).toBe('/c'); + expect(args[1]).toBe('dir'); + + process.env.ComSpec = origComSpec; + }); +}); diff --git a/apps/desktop/src/main/ai/tools/builtin/__tests__/edit.test.ts b/apps/desktop/src/main/ai/tools/builtin/__tests__/edit.test.ts new file mode 100644 index 00000000..a1e46c00 --- /dev/null +++ b/apps/desktop/src/main/ai/tools/builtin/__tests__/edit.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { editTool } from '../edit'; +import type { ToolContext } from '../../types'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('node:fs'); +vi.mock('../../../security/path-containment', () => ({ + assertPathContained: vi.fn((_filePath: string, _projectDir: string) => ({ + contained: true, + resolvedPath: _filePath, + })), +})); + +import * as fs from 'node:fs'; +import { assertPathContained } from '../../../security/path-containment'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const baseContext: ToolContext = { + cwd: '/test/project', + projectDir: '/test/project', + specDir: '/test/specs/001', + securityProfile: { + baseCommands: new Set(), + stackCommands: new Set(), + scriptCommands: new Set(), + customCommands: new Set(), + customScripts: { shellScripts: [] }, + getAllAllowedCommands: () => new Set(), + }, +} as unknown as ToolContext; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Edit Tool', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(assertPathContained).mockImplementation((_filePath: string, _projectDir: string) => ({ + contained: true, + resolvedPath: _filePath, + })); + }); + + it('should have correct metadata', () => { + expect(editTool.metadata.name).toBe('Edit'); + expect(editTool.metadata.permission).toBe('requires_approval'); + }); + + it('should successfully replace a single occurrence', async () => { + vi.mocked(fs.readFileSync).mockReturnValue('hello world foo bar'); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + + const result = await editTool.config.execute( + { + file_path: '/test/project/file.ts', + old_string: 'hello world', + new_string: 'goodbye world', + replace_all: false, + }, + baseContext, + ); + + expect(result).toContain('Successfully edited'); + expect(fs.writeFileSync).toHaveBeenCalledWith( + '/test/project/file.ts', + 'goodbye world foo bar', + 'utf-8', + ); + }); + + it('should replace all occurrences when replace_all is true', async () => { + vi.mocked(fs.readFileSync).mockReturnValue('foo bar foo baz foo'); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + + const result = await editTool.config.execute( + { + file_path: '/test/project/file.ts', + old_string: 'foo', + new_string: 'qux', + replace_all: true, + }, + baseContext, + ); + + expect(result).toContain('Successfully replaced 3 occurrence(s)'); + expect(fs.writeFileSync).toHaveBeenCalledWith( + '/test/project/file.ts', + 'qux bar qux baz qux', + 'utf-8', + ); + }); + + it('should return error when old_string not found in file', async () => { + vi.mocked(fs.readFileSync).mockReturnValue('some other content'); + + const result = await editTool.config.execute( + { + file_path: '/test/project/file.ts', + old_string: 'nonexistent text', + new_string: 'replacement', + replace_all: false, + }, + baseContext, + ); + + expect(result).toContain('Error: old_string not found'); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it('should return error when old_string matches multiple locations without replace_all', async () => { + vi.mocked(fs.readFileSync).mockReturnValue('foo foo foo'); + + const result = await editTool.config.execute( + { + file_path: '/test/project/file.ts', + old_string: 'foo', + new_string: 'bar', + replace_all: false, + }, + baseContext, + ); + + expect(result).toContain('Error: old_string appears 3 times'); + expect(result).toContain('replace_all: true'); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it('should return error when old_string equals new_string', async () => { + const result = await editTool.config.execute( + { + file_path: '/test/project/file.ts', + old_string: 'same text', + new_string: 'same text', + replace_all: false, + }, + baseContext, + ); + + expect(result).toContain('Error: old_string and new_string are identical'); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it('should return error when file not found', async () => { + const enoentError = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + vi.mocked(fs.readFileSync).mockImplementation(() => { throw enoentError; }); + + const result = await editTool.config.execute( + { + file_path: '/test/project/missing.ts', + old_string: 'old', + new_string: 'new', + replace_all: false, + }, + baseContext, + ); + + expect(result).toContain('Error: File not found'); + }); + + it('should throw non-ENOENT filesystem errors', async () => { + const permError = Object.assign(new Error('EACCES'), { code: 'EACCES' }); + vi.mocked(fs.readFileSync).mockImplementation(() => { throw permError; }); + + await expect( + editTool.config.execute( + { + file_path: '/test/project/file.ts', + old_string: 'old', + new_string: 'new', + replace_all: false, + }, + baseContext, + ), + ).rejects.toThrow('EACCES'); + }); + + it('should call assertPathContained for path security', async () => { + vi.mocked(fs.readFileSync).mockReturnValue('hello world'); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + + await editTool.config.execute( + { + file_path: '/test/project/file.ts', + old_string: 'hello world', + new_string: 'goodbye world', + replace_all: false, + }, + baseContext, + ); + + expect(assertPathContained).toHaveBeenCalledWith('/test/project/file.ts', '/test/project'); + }); + + it('should throw when path is outside project boundary', async () => { + vi.mocked(assertPathContained).mockImplementation(() => { + throw new Error("Path '/etc/passwd' is outside the project directory"); + }); + + await expect( + editTool.config.execute( + { + file_path: '/etc/passwd', + old_string: 'root', + new_string: 'hacked', + replace_all: false, + }, + baseContext, + ), + ).rejects.toThrow('outside the project directory'); + }); +}); diff --git a/apps/desktop/src/main/ai/tools/builtin/__tests__/glob.test.ts b/apps/desktop/src/main/ai/tools/builtin/__tests__/glob.test.ts new file mode 100644 index 00000000..f5d0e6e4 --- /dev/null +++ b/apps/desktop/src/main/ai/tools/builtin/__tests__/glob.test.ts @@ -0,0 +1,221 @@ +import path from 'node:path'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { globTool } from '../glob'; +import type { ToolContext } from '../../types'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('node:fs'); +vi.mock('../../../security/path-containment', () => ({ + assertPathContained: vi.fn((_filePath: string, _projectDir: string) => ({ + contained: true, + resolvedPath: _filePath, + })), +})); +vi.mock('../../truncation', () => ({ + truncateToolOutput: vi.fn((output: string) => ({ + content: output, + wasTruncated: false, + originalSize: Buffer.byteLength(output, 'utf-8'), + })), +})); + +import * as fs from 'node:fs'; +import { assertPathContained } from '../../../security/path-containment'; +import { truncateToolOutput } from '../../truncation'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const baseContext: ToolContext = { + cwd: '/test/project', + projectDir: '/test/project', + specDir: '/test/specs/001', + securityProfile: { + baseCommands: new Set(), + stackCommands: new Set(), + scriptCommands: new Set(), + customCommands: new Set(), + customScripts: { shellScripts: [] }, + getAllAllowedCommands: () => new Set(), + }, +} as unknown as ToolContext; + +/** + * Configure fs mocks for a glob run that returns the given absolute paths. + * Each path gets a fake mtime so sorting can be tested. + */ +function setupGlobMatches(absolutePaths: string[], mtimes?: number[]) { + // existsSync for the search dir + vi.mocked(fs.existsSync).mockReturnValue(true); + + // globSync returns relative filenames that the tool will resolve + const relPaths = absolutePaths.map((p) => p.replace('/test/project/', '')); + vi.mocked(fs.globSync).mockReturnValue(relPaths); + + // statSync used twice: once to check isFile, once to get mtime + let callIdx = 0; + vi.mocked(fs.statSync).mockImplementation((_p) => { + const mtime = mtimes ? mtimes[callIdx % mtimes.length] : 1000; + callIdx++; + return { + isFile: () => true, + mtimeMs: mtime, + } as unknown as fs.Stats; + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Glob Tool', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(assertPathContained).mockImplementation((_filePath: string, _projectDir: string) => ({ + contained: true, + resolvedPath: _filePath, + })); + vi.mocked(truncateToolOutput).mockImplementation((output: string) => ({ + content: output, + wasTruncated: false, + originalSize: Buffer.byteLength(output, 'utf-8'), + })); + }); + + it('should have correct metadata', () => { + expect(globTool.metadata.name).toBe('Glob'); + expect(globTool.metadata.permission).toBe('read_only'); + }); + + it('should return matching file paths', async () => { + setupGlobMatches([ + '/test/project/src/index.ts', + '/test/project/src/utils.ts', + ]); + + const result = await globTool.config.execute( + { pattern: '**/*.ts' }, + baseContext, + ) as string; + + expect(result).toContain('index.ts'); + expect(result).toContain('utils.ts'); + }); + + it('should return "No files found" when pattern matches nothing', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.globSync).mockReturnValue([]); + + const result = await globTool.config.execute( + { pattern: '**/*.nonexistent' }, + baseContext, + ); + + expect(result).toBe('No files found'); + }); + + it('should return error when search directory does not exist', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const result = await globTool.config.execute( + { pattern: '*.ts', path: '/test/project/missing-dir' }, + baseContext, + ); + + expect(result).toContain('Error: Directory not found'); + }); + + it('should sort results by mtime (most recent first)', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.globSync).mockReturnValue(['old.ts', 'new.ts', 'middle.ts']); + + // Return different mtimes for isFile check vs mtime check + // statSync is called once per file for isFile and once per file for mtime + const mtimes: Record<string, number> = { + [path.resolve('/test/project', 'old.ts')]: 1000, + [path.resolve('/test/project', 'new.ts')]: 3000, + [path.resolve('/test/project', 'middle.ts')]: 2000, + }; + + vi.mocked(fs.statSync).mockImplementation((p) => ({ + isFile: () => true, + mtimeMs: mtimes[p as string] ?? 1000, + } as unknown as fs.Stats)); + + const result = await globTool.config.execute( + { pattern: '*.ts' }, + baseContext, + ) as string; + + const lines = result.split('\n'); + const newIdx = lines.findIndex((l) => l.includes('new.ts')); + const middleIdx = lines.findIndex((l) => l.includes('middle.ts')); + const oldIdx = lines.findIndex((l) => l.includes('old.ts')); + + expect(newIdx).toBeLessThan(middleIdx); + expect(middleIdx).toBeLessThan(oldIdx); + }); + + it('should use provided path instead of cwd when given', async () => { + setupGlobMatches(['/test/project/sub/file.ts']); + + await globTool.config.execute( + { pattern: '*.ts', path: '/test/project/sub' }, + baseContext, + ); + + expect(fs.globSync).toHaveBeenCalledWith('*.ts', expect.objectContaining({ + cwd: '/test/project/sub', + })); + }); + + it('should exclude node_modules and .git from results', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.globSync).mockReturnValue(['src/index.ts']); + vi.mocked(fs.statSync).mockReturnValue({ + isFile: () => true, + mtimeMs: 1000, + } as unknown as fs.Stats); + + await globTool.config.execute( + { pattern: '**/*.ts' }, + baseContext, + ); + + // The exclude function passed to globSync should exclude node_modules/.git + const globSyncCall = vi.mocked(fs.globSync).mock.calls[0]; + const opts = globSyncCall[1] as { exclude?: (name: string) => boolean }; + expect(opts.exclude).toBeDefined(); + expect(opts.exclude?.('node_modules')).toBe(true); + expect(opts.exclude?.('.git')).toBe(true); + expect(opts.exclude?.('src')).toBe(false); + }); + + it('should call assertPathContained for path security', async () => { + setupGlobMatches([]); + vi.mocked(fs.globSync).mockReturnValue([]); + + await globTool.config.execute( + { pattern: '*.ts' }, + baseContext, + ); + + expect(assertPathContained).toHaveBeenCalledWith('/test/project', '/test/project'); + }); + + it('should pass output through truncateToolOutput', async () => { + setupGlobMatches(['/test/project/a.ts']); + + await globTool.config.execute( + { pattern: '*.ts' }, + baseContext, + ); + + expect(truncateToolOutput).toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/ai/tools/builtin/__tests__/grep.test.ts b/apps/desktop/src/main/ai/tools/builtin/__tests__/grep.test.ts new file mode 100644 index 00000000..b31686fa --- /dev/null +++ b/apps/desktop/src/main/ai/tools/builtin/__tests__/grep.test.ts @@ -0,0 +1,269 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { grepTool } from '../grep'; +import type { ToolContext } from '../../types'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockExecFile = vi.fn(); +vi.mock('node:child_process', () => ({ + execFile: (...args: unknown[]) => mockExecFile(...args), +})); + +const mockFindExecutable = vi.fn(() => '/usr/bin/rg'); + +vi.mock('../../../../platform/index', () => ({ + findExecutable: (_name: string, _additionalPaths?: string[]) => mockFindExecutable(), +})); + +vi.mock('../../../security/path-containment', () => ({ + assertPathContained: vi.fn((_filePath: string, _projectDir: string) => ({ + contained: true, + resolvedPath: _filePath, + })), +})); + +import { assertPathContained } from '../../../security/path-containment'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const baseContext: ToolContext = { + cwd: '/test/project', + projectDir: '/test/project', + specDir: '/test/specs/001', + securityProfile: { + baseCommands: new Set(), + stackCommands: new Set(), + scriptCommands: new Set(), + customCommands: new Set(), + customScripts: { shellScripts: [] }, + getAllAllowedCommands: () => new Set(), + }, +} as unknown as ToolContext; + +/** + * Set up mockExecFile to invoke the callback with the provided rg output values. + */ +function setupRg(stdout: string, stderr: string, exitCode: number) { + mockExecFile.mockImplementation( + ( + _rgPath: unknown, + _args: unknown, + _opts: unknown, + callback: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + const err = exitCode !== 0 ? Object.assign(new Error('exit'), { code: exitCode }) : null; + callback(err, stdout, stderr); + }, + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Grep Tool', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Re-set after clearAllMocks wipes the return value + mockFindExecutable.mockReturnValue('/usr/bin/rg'); + vi.mocked(assertPathContained).mockImplementation((_filePath: string, _projectDir: string) => ({ + contained: true, + resolvedPath: _filePath, + })); + }); + + it('should have correct metadata', () => { + expect(grepTool.metadata.name).toBe('Grep'); + expect(grepTool.metadata.permission).toBe('read_only'); + }); + + it('should return matching files in files_with_matches mode (default)', async () => { + setupRg('/test/project/src/index.ts\n/test/project/src/utils.ts\n', '', 0); + + const result = await grepTool.config.execute( + { pattern: 'myFunction' }, + baseContext, + ) as string; + + expect(result).toContain('/test/project/src/index.ts'); + expect(result).toContain('/test/project/src/utils.ts'); + }); + + it('should return "No matches found" when rg exits with code 1 and no stderr', async () => { + setupRg('', '', 1); + + const result = await grepTool.config.execute( + { pattern: 'nonexistent_pattern_xyz' }, + baseContext, + ); + + expect(result).toBe('No matches found'); + }); + + it('should return "No matches found" when stdout is empty', async () => { + setupRg(' \n', '', 0); + + const result = await grepTool.config.execute( + { pattern: 'something' }, + baseContext, + ); + + expect(result).toBe('No matches found'); + }); + + it('should return error message when rg exits with code > 1 and stderr', async () => { + setupRg('', 'rg: error: unknown file type\n', 2); + + const result = await grepTool.config.execute( + { pattern: 'test', type: 'unknowntype' }, + baseContext, + ) as string; + + expect(result).toContain('Error:'); + expect(result).toContain('unknown file type'); + }); + + it('should return error when ripgrep is not installed', async () => { + mockFindExecutable.mockReturnValue(null as unknown as string); + + const result = await grepTool.config.execute( + { pattern: 'test' }, + baseContext, + ) as string; + + expect(result).toContain('Error:'); + expect(result).toContain('ripgrep'); + }); + + it('should include --files-with-matches flag in default mode', async () => { + setupRg('/test/project/a.ts\n', '', 0); + + await grepTool.config.execute( + { pattern: 'hello' }, + baseContext, + ); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).toContain('--files-with-matches'); + }); + + it('should include --line-number flag in content mode', async () => { + setupRg('src/a.ts:10:const hello = 1;\n', '', 0); + + await grepTool.config.execute( + { pattern: 'hello', output_mode: 'content' }, + baseContext, + ); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).toContain('--line-number'); + expect(args).not.toContain('--files-with-matches'); + expect(args).not.toContain('--count'); + }); + + it('should include --count flag in count mode', async () => { + setupRg('src/a.ts:5\n', '', 0); + + await grepTool.config.execute( + { pattern: 'hello', output_mode: 'count' }, + baseContext, + ); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).toContain('--count'); + }); + + it('should add -C flag when context lines are specified in content mode', async () => { + setupRg('match output\n', '', 0); + + await grepTool.config.execute( + { pattern: 'hello', output_mode: 'content', context: 3 }, + baseContext, + ); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).toContain('-C'); + expect(args).toContain('3'); + }); + + it('should add --type flag when type is specified', async () => { + setupRg('/test/project/a.ts\n', '', 0); + + await grepTool.config.execute( + { pattern: 'hello', type: 'ts' }, + baseContext, + ); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).toContain('--type'); + expect(args).toContain('ts'); + }); + + it('should add --glob flag when glob is specified', async () => { + setupRg('/test/project/src/a.ts\n', '', 0); + + await grepTool.config.execute( + { pattern: 'hello', glob: '*.{ts,tsx}' }, + baseContext, + ); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).toContain('--glob'); + expect(args).toContain('*.{ts,tsx}'); + }); + + it('should truncate output exceeding MAX_OUTPUT_LENGTH', async () => { + const longOutput = '/test/project/file.ts\n'.repeat(2000); + setupRg(longOutput, '', 0); + + const result = await grepTool.config.execute( + { pattern: 'test' }, + baseContext, + ) as string; + + expect(result).toContain('[Output truncated'); + expect(result.length).toBeLessThan(longOutput.length); + }); + + it('should call assertPathContained for path security', async () => { + setupRg('/test/project/a.ts\n', '', 0); + + await grepTool.config.execute( + { pattern: 'hello' }, + baseContext, + ); + + expect(assertPathContained).toHaveBeenCalledWith('/test/project', '/test/project'); + }); + + it('should throw when search path is outside project boundary', async () => { + vi.mocked(assertPathContained).mockImplementation(() => { + throw new Error("Path '/etc' is outside the project directory"); + }); + + await expect( + grepTool.config.execute( + { pattern: 'root', path: '/etc' }, + baseContext, + ), + ).rejects.toThrow('outside the project directory'); + }); + + it('should use provided path for search instead of cwd', async () => { + setupRg('/test/project/sub/a.ts\n', '', 0); + + await grepTool.config.execute( + { pattern: 'hello', path: '/test/project/sub' }, + baseContext, + ); + + const args = mockExecFile.mock.calls[0][1] as string[]; + // The resolved search path should be the last argument before the pattern + expect(args).toContain('/test/project/sub'); + }); +}); diff --git a/apps/desktop/src/main/ai/tools/builtin/__tests__/read.test.ts b/apps/desktop/src/main/ai/tools/builtin/__tests__/read.test.ts new file mode 100644 index 00000000..bf383f11 --- /dev/null +++ b/apps/desktop/src/main/ai/tools/builtin/__tests__/read.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { readTool } from '../read'; +import type { ToolContext } from '../../types'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('node:fs'); +vi.mock('../../../security/path-containment', () => ({ + assertPathContained: vi.fn((_filePath: string, _projectDir: string) => ({ + contained: true, + resolvedPath: _filePath, + })), +})); + +import * as fs from 'node:fs'; +import { assertPathContained } from '../../../security/path-containment'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const baseContext: ToolContext = { + cwd: '/test/project', + projectDir: '/test/project', + specDir: '/test/specs/001', + securityProfile: { + baseCommands: new Set(), + stackCommands: new Set(), + scriptCommands: new Set(), + customCommands: new Set(), + customScripts: { shellScripts: [] }, + getAllAllowedCommands: () => new Set(), + }, +} as unknown as ToolContext; + +/** + * Set up the fs mock sequence for a successful text file read. + * + * openSync → fd, fstatSync → stat object, readFileSync → content, closeSync → void + */ +function setupTextFile(content: string, isDir = false) { + const fakeFd = 42; + vi.mocked(fs.openSync).mockReturnValue(fakeFd as unknown as number); + vi.mocked(fs.fstatSync).mockReturnValue({ + isDirectory: () => isDir, + size: Buffer.byteLength(content), + } as unknown as fs.Stats); + vi.mocked(fs.readFileSync).mockReturnValue(content); + vi.mocked(fs.closeSync).mockImplementation(() => undefined); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Read Tool', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(assertPathContained).mockImplementation((_filePath: string, _projectDir: string) => ({ + contained: true, + resolvedPath: _filePath, + })); + }); + + it('should have correct metadata', () => { + expect(readTool.metadata.name).toBe('Read'); + expect(readTool.metadata.permission).toBe('read_only'); + }); + + it('should read an entire file with line numbers', async () => { + setupTextFile('line one\nline two\nline three'); + + const result = await readTool.config.execute( + { file_path: '/test/project/file.ts' }, + baseContext, + ); + + expect(result).toContain('line one'); + expect(result).toContain('line two'); + expect(result).toContain('line three'); + // Line numbers should be present (cat -n style) + expect(result).toMatch(/\d+\t/); + }); + + it('should format output with correct line numbers', async () => { + setupTextFile('alpha\nbeta\ngamma'); + + const result = await readTool.config.execute( + { file_path: '/test/project/file.ts' }, + baseContext, + ) as string; + + const lines = result.split('\n'); + expect(lines[0]).toMatch(/^\s*1\talpha/); + expect(lines[1]).toMatch(/^\s*2\tbeta/); + expect(lines[2]).toMatch(/^\s*3\tgamma/); + }); + + it('should respect offset and limit parameters', async () => { + const content = 'line1\nline2\nline3\nline4\nline5'; + setupTextFile(content); + + const result = await readTool.config.execute( + { file_path: '/test/project/file.ts', offset: 1, limit: 2 }, + baseContext, + ) as string; + + // offset=1 means start from line index 1 (line2), limit=2 means two lines + expect(result).toContain('line2'); + expect(result).toContain('line3'); + expect(result).not.toContain('line1'); + expect(result).not.toContain('line4'); + }); + + it('should show truncation notice when there are more lines beyond limit', async () => { + const lines = Array.from({ length: 10 }, (_, i) => `line${i + 1}`); + setupTextFile(lines.join('\n')); + + const result = await readTool.config.execute( + { file_path: '/test/project/file.ts', offset: 0, limit: 3 }, + baseContext, + ) as string; + + expect(result).toContain('Showing lines 1-3 of 10 total lines'); + }); + + it('should return error when file not found', async () => { + const enoentError = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + vi.mocked(fs.openSync).mockImplementation(() => { throw enoentError; }); + + const result = await readTool.config.execute( + { file_path: '/test/project/missing.ts' }, + baseContext, + ); + + expect(result).toContain('Error: File not found'); + }); + + it('should return error when path is a directory (EISDIR)', async () => { + const eisdirError = Object.assign(new Error('EISDIR'), { code: 'EISDIR' }); + vi.mocked(fs.openSync).mockImplementation(() => { throw eisdirError; }); + + const result = await readTool.config.execute( + { file_path: '/test/project/somedir' }, + baseContext, + ); + + expect(result).toContain('is a directory'); + }); + + it('should return empty file message when file has no content', async () => { + setupTextFile(''); + + const result = await readTool.config.execute( + { file_path: '/test/project/empty.ts' }, + baseContext, + ); + + expect(result).toContain('File exists but is empty'); + }); + + it('should return image file as base64 data URI', async () => { + const fakeFd = 42; + const imageBuffer = Buffer.from('fake-png-data'); + vi.mocked(fs.openSync).mockReturnValue(fakeFd as unknown as number); + vi.mocked(fs.fstatSync).mockReturnValue({ + isDirectory: () => false, + size: imageBuffer.length, + } as unknown as fs.Stats); + // readFileSync returns Buffer for image files + vi.mocked(fs.readFileSync).mockReturnValue(imageBuffer); + vi.mocked(fs.closeSync).mockImplementation(() => undefined); + + const result = await readTool.config.execute( + { file_path: '/test/project/image.png' }, + baseContext, + ) as string; + + expect(result).toContain('[Image file:'); + expect(result).toContain('data:image/png;base64,'); + }); + + it('should return PDF info without pages parameter', async () => { + const fakeFd = 42; + vi.mocked(fs.openSync).mockReturnValue(fakeFd as unknown as number); + vi.mocked(fs.fstatSync).mockReturnValue({ + isDirectory: () => false, + size: 102400, + } as unknown as fs.Stats); + vi.mocked(fs.closeSync).mockImplementation(() => undefined); + + const result = await readTool.config.execute( + { file_path: '/test/project/doc.pdf' }, + baseContext, + ) as string; + + expect(result).toContain('[PDF file:'); + expect(result).toContain('pages'); + }); + + it('should call assertPathContained for path security', async () => { + setupTextFile('content'); + + await readTool.config.execute( + { file_path: '/test/project/file.ts' }, + baseContext, + ); + + expect(assertPathContained).toHaveBeenCalledWith('/test/project/file.ts', '/test/project'); + }); + + it('should throw when path is outside project boundary', async () => { + vi.mocked(assertPathContained).mockImplementation(() => { + throw new Error("Path '/etc/passwd' is outside the project directory"); + }); + + await expect( + readTool.config.execute( + { file_path: '/etc/passwd' }, + baseContext, + ), + ).rejects.toThrow('outside the project directory'); + }); +}); diff --git a/apps/desktop/src/main/ai/tools/builtin/__tests__/write.test.ts b/apps/desktop/src/main/ai/tools/builtin/__tests__/write.test.ts new file mode 100644 index 00000000..913904d8 --- /dev/null +++ b/apps/desktop/src/main/ai/tools/builtin/__tests__/write.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { writeTool } from '../write'; +import type { ToolContext } from '../../types'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('node:fs'); +vi.mock('../../../security/path-containment', () => ({ + assertPathContained: vi.fn((_filePath: string, _projectDir: string) => ({ + contained: true, + resolvedPath: _filePath, + })), +})); + +import * as fs from 'node:fs'; +import { assertPathContained } from '../../../security/path-containment'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const baseContext: ToolContext = { + cwd: '/test/project', + projectDir: '/test/project', + specDir: '/test/specs/001', + securityProfile: { + baseCommands: new Set(), + stackCommands: new Set(), + scriptCommands: new Set(), + customCommands: new Set(), + customScripts: { shellScripts: [] }, + getAllAllowedCommands: () => new Set(), + }, +} as unknown as ToolContext; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Write Tool', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(assertPathContained).mockImplementation((_filePath: string, _projectDir: string) => ({ + contained: true, + resolvedPath: _filePath, + })); + // Parent directory exists by default + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + }); + + it('should have correct metadata', () => { + expect(writeTool.metadata.name).toBe('Write'); + expect(writeTool.metadata.permission).toBe('requires_approval'); + }); + + it('should write a new file and report line count', async () => { + const content = 'line one\nline two\nline three'; + + const result = await writeTool.config.execute( + { file_path: '/test/project/new-file.ts', content }, + baseContext, + ); + + expect(result).toContain('Successfully wrote 3 lines'); + expect(result).toContain('/test/project/new-file.ts'); + expect(fs.writeFileSync).toHaveBeenCalledWith( + '/test/project/new-file.ts', + content, + 'utf-8', + ); + }); + + it('should overwrite an existing file', async () => { + const content = 'updated content'; + + const result = await writeTool.config.execute( + { file_path: '/test/project/existing.ts', content }, + baseContext, + ); + + expect(result).toContain('Successfully wrote'); + expect(fs.writeFileSync).toHaveBeenCalledWith( + '/test/project/existing.ts', + content, + 'utf-8', + ); + }); + + it('should create parent directories when they do not exist', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.mkdirSync).mockImplementation(() => undefined); + + await writeTool.config.execute( + { file_path: '/test/project/new/deep/file.ts', content: 'content' }, + baseContext, + ); + + expect(fs.mkdirSync).toHaveBeenCalledWith( + '/test/project/new/deep', + { recursive: true }, + ); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it('should not create directories when parent already exists', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + + await writeTool.config.execute( + { file_path: '/test/project/file.ts', content: 'content' }, + baseContext, + ); + + expect(fs.mkdirSync).not.toHaveBeenCalled(); + }); + + it('should count lines correctly for single-line content', async () => { + const result = await writeTool.config.execute( + { file_path: '/test/project/file.ts', content: 'single line' }, + baseContext, + ); + + expect(result).toContain('Successfully wrote 1 lines'); + }); + + it('should count CRLF lines correctly', async () => { + const content = 'line1\r\nline2\r\nline3'; + + const result = await writeTool.config.execute( + { file_path: '/test/project/file.ts', content }, + baseContext, + ); + + // split(/\r?\n/) yields 3 parts + expect(result).toContain('Successfully wrote 3 lines'); + }); + + it('should call assertPathContained for path security', async () => { + await writeTool.config.execute( + { file_path: '/test/project/file.ts', content: 'hello' }, + baseContext, + ); + + expect(assertPathContained).toHaveBeenCalledWith('/test/project/file.ts', '/test/project'); + }); + + it('should throw when path is outside project boundary', async () => { + vi.mocked(assertPathContained).mockImplementation(() => { + throw new Error("Path '/etc/hosts' is outside the project directory"); + }); + + await expect( + writeTool.config.execute( + { file_path: '/etc/hosts', content: 'malicious' }, + baseContext, + ), + ).rejects.toThrow('outside the project directory'); + + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts index 199ca6ef..50bc78b9 100644 --- a/apps/desktop/vitest.config.ts +++ b/apps/desktop/vitest.config.ts @@ -9,9 +9,15 @@ export default defineConfig({ exclude: ['node_modules', 'dist', 'out'], coverage: { provider: 'v8', - reporter: ['text', 'json', 'html'], + reporter: ['text', 'json', 'html', 'json-summary'], include: ['src/**/*.ts', 'src/**/*.tsx'], - exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts', 'src/**/*.spec.tsx', 'src/**/*.d.ts'] + exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts', 'src/**/*.spec.tsx', 'src/**/*.d.ts'], + thresholds: { + lines: 22, + branches: 17, + functions: 19, + statements: 22 + } }, // Mock Electron modules for unit tests alias: { diff --git a/package-lock.json b/package-lock.json index 8d79d53e..a9ecab4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -116,6 +116,7 @@ "@types/semver": "^7.7.1", "@types/uuid": "^11.0.0", "@vitejs/plugin-react": "^5.1.2", + "@vitest/coverage-v8": "^4.1.0", "autoprefixer": "^10.4.22", "cross-env": "^10.1.0", "electron": "40.0.0", @@ -788,13 +789,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -895,9 +896,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { @@ -908,6 +909,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@biomejs/biome": { "version": "2.3.11", "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.11.tgz", @@ -5968,18 +5979,49 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/@vitest/expect": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.17.tgz", - "integrity": "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==", + "node_modules/@vitest/coverage-v8": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz", + "integrity": "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.0", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.0", + "vitest": "4.1.0" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", - "chai": "^6.2.1", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", "tinyrainbow": "^3.0.3" }, "funding": { @@ -5987,13 +6029,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz", - "integrity": "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.17", + "@vitest/spy": "4.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -6002,7 +6044,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -6014,9 +6056,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz", - "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", "dev": true, "license": "MIT", "dependencies": { @@ -6027,13 +6069,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz", - "integrity": "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.17", + "@vitest/utils": "4.1.0", "pathe": "^2.0.3" }, "funding": { @@ -6041,13 +6083,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz", - "integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.17", + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -6056,9 +6099,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz", - "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", "dev": true, "license": "MIT", "funding": { @@ -6066,13 +6109,14 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz", - "integrity": "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.17", + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" }, "funding": { @@ -6532,6 +6576,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -8423,9 +8486,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -9564,6 +9627,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -9966,6 +10036,45 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -10789,6 +10898,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/make-fetch-happen": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", @@ -13927,9 +14064,9 @@ } }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true, "license": "MIT" }, @@ -15468,31 +15605,31 @@ } }, "node_modules/vitest": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz", - "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.17", - "@vitest/mocker": "4.0.17", - "@vitest/pretty-format": "4.0.17", - "@vitest/runner": "4.0.17", - "@vitest/snapshot": "4.0.17", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -15508,12 +15645,13 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.17", - "@vitest/browser-preview": "4.0.17", - "@vitest/browser-webdriverio": "4.0.17", - "@vitest/ui": "4.0.17", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -15542,6 +15680,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, diff --git a/scripts/update-readme.mjs b/scripts/update-readme.mjs new file mode 100644 index 00000000..01590a28 --- /dev/null +++ b/scripts/update-readme.mjs @@ -0,0 +1,202 @@ +#!/usr/bin/env node +/** + * Update README.md version badges and download links. + * + * Usage: + * node scripts/update-readme.mjs <version> [--prerelease] + * + * Examples: + * node scripts/update-readme.mjs 2.8.0 # Stable release + * node scripts/update-readme.mjs 2.8.0-beta.1 --prerelease # Beta release + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { argv, stderr, exit } from 'node:process'; + +// Semver pattern: X.Y.Z or X.Y.Z-prerelease.N +const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[a-zA-Z]+\.\d+)?$/; + +/** + * Validate version string matches semver format. + * @param {string} version + * @returns {boolean} + */ +export function validateVersion(version) { + return SEMVER_PATTERN.test(version); +} + +/** + * Escape a string for use in a RegExp. + * @param {string} str + * @returns {string} + */ +function escapeRegExp(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Update content between markers with given replacements. + * @param {string} text + * @param {string} startMarker + * @param {string} endMarker + * @param {Array<[string, string]>} replacements - [regexPattern, replacement] pairs + * @returns {string} + */ +export function updateSection(text, startMarker, endMarker, replacements) { + const pattern = new RegExp( + `(${escapeRegExp(startMarker)})(.*?)(${escapeRegExp(endMarker)})`, + 's', // dotAll flag — equivalent to re.DOTALL + ); + + return text.replace(pattern, (_match, g1, section, g3) => { + let updated = section; + for (const [oldPattern, newValue] of replacements) { + updated = updated.replace(new RegExp(oldPattern, 'g'), newValue); + } + return g1 + updated + g3; + }); +} + +/** + * Update README.md with new version. + * @param {string} version - Version string (e.g., "2.8.0" or "2.8.0-beta.1") + * @param {boolean} isPrerelease - Whether this is a prerelease version + * @returns {boolean} True if changes were made, false otherwise + */ +export function updateReadme(version, isPrerelease) { + // Shields.io escapes hyphens as -- + const versionBadge = version.replaceAll('-', '--'); + + // Read README + const originalContent = readFileSync('README.md', 'utf8'); + let content = originalContent; + + // Semver pattern: matches X.Y.Z or X.Y.Z-prerelease (e.g., 2.7.2, 2.7.2-beta.10) + // Prerelease MUST contain a dot (beta.10, alpha.1, rc.1) to avoid matching platform suffixes (win32, darwin) + const semver = String.raw`\d+\.\d+\.\d+(?:-[a-zA-Z]+\.[a-zA-Z0-9.]+)?`; + // Shields.io escaped pattern (hyphens as --) + const semverBadge = String.raw`\d+\.\d+\.\d+(?:--[a-zA-Z]+\.[a-zA-Z0-9.]+)?`; + + if (isPrerelease) { + console.log(`Updating BETA section to ${version} (badge: ${versionBadge})`); + + // Update beta badge + content = content.replace( + new RegExp(`beta-${semverBadge}-orange`, 'g'), + `beta-${versionBadge}-orange`, + ); + + // Update beta version badge link + content = updateSection( + content, + '<!-- BETA_VERSION_BADGE -->', + '<!-- BETA_VERSION_BADGE_END -->', + [[`tag/v${semver}\\)`, `tag/v${version})`]], + ); + + // Update beta downloads + content = updateSection( + content, + '<!-- BETA_DOWNLOADS -->', + '<!-- BETA_DOWNLOADS_END -->', + [ + [`Auto-Claude-${semver}`, `Auto-Claude-${version}`], + [`download/v${semver}/`, `download/v${version}/`], + ], + ); + } else { + console.log(`Updating STABLE section to ${version} (badge: ${versionBadge})`); + + // Update top version badge + content = updateSection( + content, + '<!-- TOP_VERSION_BADGE -->', + '<!-- TOP_VERSION_BADGE_END -->', + [ + [`version-${semverBadge}-blue`, `version-${versionBadge}-blue`], + [`tag/v${semver}\\)`, `tag/v${version})`], + ], + ); + + // Update stable badge + content = content.replace( + new RegExp(`stable-${semverBadge}-blue`, 'g'), + `stable-${versionBadge}-blue`, + ); + + // Update stable version badge link + content = updateSection( + content, + '<!-- STABLE_VERSION_BADGE -->', + '<!-- STABLE_VERSION_BADGE_END -->', + [[`tag/v${semver}\\)`, `tag/v${version})`]], + ); + + // Update stable downloads + content = updateSection( + content, + '<!-- STABLE_DOWNLOADS -->', + '<!-- STABLE_DOWNLOADS_END -->', + [ + [`Auto-Claude-${semver}`, `Auto-Claude-${version}`], + [`download/v${semver}/`, `download/v${version}/`], + ], + ); + } + + // Check if changes were made + if (content === originalContent) { + console.log('No changes needed'); + return false; + } + + // Write updated README + writeFileSync('README.md', content, 'utf8'); + + console.log(`README.md updated for ${version} (prerelease=${isPrerelease})`); + return true; +} + +function main() { + const args = argv.slice(2); + + // Parse args: <version> [--prerelease] + const versionArg = args.find((a) => !a.startsWith('--')); + const isPrereleaseFlag = args.includes('--prerelease'); + + if (!versionArg) { + stderr.write('usage: node scripts/update-readme.mjs <version> [--prerelease]\n'); + exit(1); + } + + // Validate version format + if (!validateVersion(versionArg)) { + stderr.write(`ERROR: Invalid version format: ${versionArg}\n`); + stderr.write('Expected format: X.Y.Z or X.Y.Z-prerelease.N (e.g., 2.8.0 or 2.8.0-beta.1)\n'); + exit(1); + } + + // Auto-detect prerelease if not explicitly set + const isPrerelease = isPrereleaseFlag || versionArg.includes('-'); + + try { + updateReadme(versionArg, isPrerelease); + exit(0); + } catch (err) { + if (err.code === 'ENOENT') { + stderr.write('ERROR: README.md not found\n'); + } else { + stderr.write(`ERROR: ${err.message}\n`); + } + exit(1); + } +} + +// Only run when invoked directly (not when imported by tests) +const isMain = + argv[1] && + (await import('node:url')).fileURLToPath(import.meta.url) === argv[1]; + +if (isMain) { + main(); +} diff --git a/scripts/update-readme.py b/scripts/update-readme.py deleted file mode 100644 index 3ef12e96..00000000 --- a/scripts/update-readme.py +++ /dev/null @@ -1,164 +0,0 @@ -#!/usr/bin/env python3 -""" -Update README.md version badges and download links. - -Usage: - python scripts/update-readme.py <version> [--prerelease] - -Examples: - python scripts/update-readme.py 2.8.0 # Stable release - python scripts/update-readme.py 2.8.0-beta.1 --prerelease # Beta release -""" -import argparse -import re -import sys - -# Semver pattern: X.Y.Z or X.Y.Z-prerelease.N -SEMVER_PATTERN = re.compile(r"^\d+\.\d+\.\d+(-[a-zA-Z]+\.\d+)?$") - - -def validate_version(version: str) -> bool: - """Validate version string matches semver format.""" - return bool(SEMVER_PATTERN.match(version)) - - -def update_section(text: str, start_marker: str, end_marker: str, replacements: list) -> str: - """Update content between markers with given replacements.""" - pattern = f"({re.escape(start_marker)})(.*?)({re.escape(end_marker)})" - - def replace_section(match): - section = match.group(2) - for old_pattern, new_value in replacements: - section = re.sub(old_pattern, new_value, section) - return match.group(1) + section + match.group(3) - - return re.sub(pattern, replace_section, text, flags=re.DOTALL) - - -def update_readme(version: str, is_prerelease: bool) -> bool: - """ - Update README.md with new version. - - Args: - version: Version string (e.g., "2.8.0" or "2.8.0-beta.1") - is_prerelease: Whether this is a prerelease version - - Returns: - True if changes were made, False otherwise - """ - # Shields.io escapes hyphens as -- - version_badge = version.replace("-", "--") - - # Read README - with open("README.md", "r") as f: - original_content = f.read() - - content = original_content - - # Semver pattern: matches X.Y.Z or X.Y.Z-prerelease (e.g., 2.7.2, 2.7.2-beta.10) - # Prerelease MUST contain a dot (beta.10, alpha.1, rc.1) to avoid matching platform suffixes (win32, darwin) - semver = r"\d+\.\d+\.\d+(?:-[a-zA-Z]+\.[a-zA-Z0-9.]+)?" - # Shields.io escaped pattern (hyphens as --) - semver_badge = r"\d+\.\d+\.\d+(?:--[a-zA-Z]+\.[a-zA-Z0-9.]+)?" - - if is_prerelease: - print(f"Updating BETA section to {version} (badge: {version_badge})") - - # Update beta badge - content = re.sub(rf"beta-{semver_badge}-orange", f"beta-{version_badge}-orange", content) - - # Update beta version badge link - content = update_section( - content, - "<!-- BETA_VERSION_BADGE -->", - "<!-- BETA_VERSION_BADGE_END -->", - [(rf"tag/v{semver}\)", f"tag/v{version})")], - ) - - # Update beta downloads - content = update_section( - content, - "<!-- BETA_DOWNLOADS -->", - "<!-- BETA_DOWNLOADS_END -->", - [ - (rf"Auto-Claude-{semver}", f"Auto-Claude-{version}"), - (rf"download/v{semver}/", f"download/v{version}/"), - ], - ) - else: - print(f"Updating STABLE section to {version} (badge: {version_badge})") - - # Update top version badge - content = update_section( - content, - "<!-- TOP_VERSION_BADGE -->", - "<!-- TOP_VERSION_BADGE_END -->", - [ - (rf"version-{semver_badge}-blue", f"version-{version_badge}-blue"), - (rf"tag/v{semver}\)", f"tag/v{version})"), - ], - ) - - # Update stable badge - content = re.sub(rf"stable-{semver_badge}-blue", f"stable-{version_badge}-blue", content) - - # Update stable version badge link - content = update_section( - content, - "<!-- STABLE_VERSION_BADGE -->", - "<!-- STABLE_VERSION_BADGE_END -->", - [(rf"tag/v{semver}\)", f"tag/v{version})")], - ) - - # Update stable downloads - content = update_section( - content, - "<!-- STABLE_DOWNLOADS -->", - "<!-- STABLE_DOWNLOADS_END -->", - [ - (rf"Auto-Claude-{semver}", f"Auto-Claude-{version}"), - (rf"download/v{semver}/", f"download/v{version}/"), - ], - ) - - # Check if changes were made - if content == original_content: - print("No changes needed") - return False - - # Write updated README - with open("README.md", "w") as f: - f.write(content) - - print(f"README.md updated for {version} (prerelease={is_prerelease})") - return True - - -def main(): - parser = argparse.ArgumentParser(description="Update README.md version badges and download links") - parser.add_argument("version", help="Version string (e.g., 2.8.0 or 2.8.0-beta.1)") - parser.add_argument("--prerelease", action="store_true", help="Mark as prerelease version") - args = parser.parse_args() - - # Validate version format - if not validate_version(args.version): - print(f"ERROR: Invalid version format: {args.version}", file=sys.stderr) - print("Expected format: X.Y.Z or X.Y.Z-prerelease.N (e.g., 2.8.0 or 2.8.0-beta.1)", file=sys.stderr) - sys.exit(1) - - # Auto-detect prerelease if not explicitly set - is_prerelease = args.prerelease or ("-" in args.version) - - try: - changed = update_readme(args.version, is_prerelease) - sys.exit(0 if changed else 0) # Exit 0 in both cases (no error) - except FileNotFoundError: - print("ERROR: README.md not found", file=sys.stderr) - sys.exit(1) - except Exception as e: - print(f"ERROR: {e}", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/scripts/update-readme.test.mjs b/scripts/update-readme.test.mjs new file mode 100644 index 00000000..27dd9738 --- /dev/null +++ b/scripts/update-readme.test.mjs @@ -0,0 +1,232 @@ +/** + * Tests for update-readme.mjs + * Run with: node --test scripts/update-readme.test.mjs + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { chdir, cwd } from 'node:process'; + +import { validateVersion, updateSection, updateReadme } from './update-readme.mjs'; + +// --------------------------------------------------------------------------- +// validateVersion +// --------------------------------------------------------------------------- + +test('validateVersion - accepts valid stable versions', () => { + assert.equal(validateVersion('2.8.0'), true); + assert.equal(validateVersion('1.0.0'), true); + assert.equal(validateVersion('10.20.30'), true); +}); + +test('validateVersion - accepts valid prerelease versions', () => { + assert.equal(validateVersion('2.8.0-beta.1'), true); + assert.equal(validateVersion('2.8.0-alpha.10'), true); + assert.equal(validateVersion('1.0.0-rc.3'), true); +}); + +test('validateVersion - rejects invalid versions', () => { + assert.equal(validateVersion('2.8'), false); + assert.equal(validateVersion('2.8.0.1'), false); + assert.equal(validateVersion('v2.8.0'), false); + assert.equal(validateVersion('2.8.0-beta'), false); // missing .N + assert.equal(validateVersion(''), false); + assert.equal(validateVersion('abc'), false); + assert.equal(validateVersion('2.8.0-win32'), false); // no dot suffix +}); + +// --------------------------------------------------------------------------- +// updateSection +// --------------------------------------------------------------------------- + +test('updateSection - replaces content between markers', () => { + const content = [ + 'before', + '<!-- START -->', + 'tag/v2.7.0)', + '<!-- END -->', + 'after', + ].join('\n'); + + const result = updateSection( + content, + '<!-- START -->', + '<!-- END -->', + [[String.raw`tag/v\d+\.\d+\.\d+\)`, 'tag/v2.8.0)']], + ); + + assert.ok(result.includes('tag/v2.8.0)'), 'should update the version inside the section'); + assert.ok(result.includes('before'), 'should keep content before markers'); + assert.ok(result.includes('after'), 'should keep content after markers'); +}); + +test('updateSection - applies multiple replacements in order', () => { + const content = [ + '<!-- S -->', + 'Auto-Claude-2.7.0-mac.dmg download/v2.7.0/file', + '<!-- E -->', + ].join('\n'); + + const semver = String.raw`\d+\.\d+\.\d+(?:-[a-zA-Z]+\.[a-zA-Z0-9.]+)?`; + const result = updateSection(content, '<!-- S -->', '<!-- E -->', [ + [`Auto-Claude-${semver}`, 'Auto-Claude-2.8.0'], + [`download/v${semver}/`, 'download/v2.8.0/'], + ]); + + assert.ok(result.includes('Auto-Claude-2.8.0'), 'should replace filename'); + assert.ok(result.includes('download/v2.8.0/'), 'should replace download path'); +}); + +test('updateSection - handles multiline sections (dotAll)', () => { + const content = '<!-- M -->\nline1\nline2\n<!-- M_END -->'; + const result = updateSection(content, '<!-- M -->', '<!-- M_END -->', [ + ['line1', 'replaced'], + ]); + assert.ok(result.includes('replaced')); + assert.ok(result.includes('line2')); +}); + +test('updateSection - no markers leaves text unchanged', () => { + const content = 'no markers here'; + const result = updateSection(content, '<!-- A -->', '<!-- B -->', [['x', 'y']]); + assert.equal(result, content); +}); + +// --------------------------------------------------------------------------- +// updateReadme - stable release +// --------------------------------------------------------------------------- + +/** + * Build a minimal README with all section markers used by the script. + * + * Note: The download URL path (download/v${version}/) is what gets tested for + * version replacement. The filename after the version uses "-win" (no dot) + * so the semver regex — which requires a dot inside any prerelease-like suffix + * — does NOT greedily consume the platform suffix as part of the version. + */ +function buildSampleReadme(stableVersion, betaVersion) { + const sv = stableVersion; + const bv = betaVersion; + // Shields.io badge format uses -- for hyphens + const svBadge = sv.replaceAll('-', '--'); + const bvBadge = bv.replaceAll('-', '--'); + + return [ + `<!-- TOP_VERSION_BADGE -->`, + `[![version](https://img.shields.io/badge/version-${svBadge}-blue)](https://github.com/example/releases/tag/v${sv})`, + `<!-- TOP_VERSION_BADGE_END -->`, + ``, + `[![stable](https://img.shields.io/badge/stable-${svBadge}-blue)](https://example.com)`, + ``, + `<!-- STABLE_VERSION_BADGE -->`, + `[v${sv}](https://github.com/example/releases/tag/v${sv})`, + `<!-- STABLE_VERSION_BADGE_END -->`, + ``, + `<!-- STABLE_DOWNLOADS -->`, + `https://example.com/download/v${sv}/Auto-Claude-${sv}-win`, + `<!-- STABLE_DOWNLOADS_END -->`, + ``, + `[![beta](https://img.shields.io/badge/beta-${bvBadge}-orange)](https://example.com)`, + ``, + `<!-- BETA_VERSION_BADGE -->`, + `[v${bv}](https://github.com/example/releases/tag/v${bv})`, + `<!-- BETA_VERSION_BADGE_END -->`, + ``, + `<!-- BETA_DOWNLOADS -->`, + `https://example.com/download/v${bv}/Auto-Claude-${bv}-win`, + `<!-- BETA_DOWNLOADS_END -->`, + ].join('\n'); +} + +/** Run updateReadme in a temp directory with a fixture README.md. */ +function withTempReadme(readmeContent, fn) { + const tmpDir = mkdtempSync(join(tmpdir(), 'update-readme-test-')); + const original = cwd(); + try { + writeFileSync(join(tmpDir, 'README.md'), readmeContent, 'utf8'); + chdir(tmpDir); + fn(tmpDir); + } finally { + chdir(original); + rmSync(tmpDir, { recursive: true, force: true }); + } +} + +test('updateReadme - stable release updates TOP_VERSION_BADGE, STABLE_VERSION_BADGE, STABLE_DOWNLOADS', () => { + const readme = buildSampleReadme('2.7.0', '2.8.0-beta.1'); + + withTempReadme(readme, (dir) => { + const changed = updateReadme('2.8.0', false); + assert.equal(changed, true, 'should report changes made'); + + const result = readFileSync(join(dir, 'README.md'), 'utf8'); + + // TOP_VERSION_BADGE section updated + assert.ok(result.includes('version-2.8.0-blue'), 'top badge version updated'); + assert.ok(result.includes('tag/v2.8.0)'), 'top badge link updated'); + + // Stable badge outside section updated + assert.ok(result.includes('stable-2.8.0-blue'), 'standalone stable badge updated'); + + // STABLE_VERSION_BADGE section updated + assert.ok(result.includes('tag/v2.8.0)'), 'stable version link updated'); + + // STABLE_DOWNLOADS section updated + assert.ok(result.includes('download/v2.8.0/'), 'stable download path updated'); + assert.ok(result.includes('Auto-Claude-2.8.0'), 'stable download filename updated'); + + // Beta section NOT modified + assert.ok(result.includes('beta-2.8.0--beta.1-orange'), 'beta badge unchanged'); + assert.ok(result.includes('download/v2.8.0-beta.1/'), 'beta download unchanged'); + }); +}); + +test('updateReadme - stable release returns false when no changes needed', () => { + const readme = buildSampleReadme('2.8.0', '2.8.0-beta.1'); + + withTempReadme(readme, () => { + const changed = updateReadme('2.8.0', false); + assert.equal(changed, false, 'should report no changes when already up to date'); + }); +}); + +// --------------------------------------------------------------------------- +// updateReadme - prerelease +// --------------------------------------------------------------------------- + +test('updateReadme - prerelease updates BETA_VERSION_BADGE and BETA_DOWNLOADS', () => { + const readme = buildSampleReadme('2.7.0', '2.7.0-beta.5'); + + withTempReadme(readme, (dir) => { + const changed = updateReadme('2.8.0-beta.1', true); + assert.equal(changed, true, 'should report changes made'); + + const result = readFileSync(join(dir, 'README.md'), 'utf8'); + + // Beta badge updated (-- escaped) + assert.ok(result.includes('beta-2.8.0--beta.1-orange'), 'beta badge updated'); + + // BETA_VERSION_BADGE section updated + assert.ok(result.includes('tag/v2.8.0-beta.1)'), 'beta version link updated'); + + // BETA_DOWNLOADS section updated + assert.ok(result.includes('download/v2.8.0-beta.1/'), 'beta download path updated'); + assert.ok(result.includes('Auto-Claude-2.8.0-beta.1'), 'beta download filename updated'); + + // Stable section NOT modified + assert.ok(result.includes('stable-2.7.0-blue'), 'stable badge unchanged'); + assert.ok(result.includes('download/v2.7.0/'), 'stable download unchanged'); + }); +}); + +test('updateReadme - prerelease returns false when no changes needed', () => { + const readme = buildSampleReadme('2.7.0', '2.8.0-beta.1'); + + withTempReadme(readme, () => { + const changed = updateReadme('2.8.0-beta.1', true); + assert.equal(changed, false, 'should report no changes when already up to date'); + }); +});