diff --git a/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts new file mode 100644 index 00000000..b7146d82 --- /dev/null +++ b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'bun:test'; +import { + DEFAULT_IMAGE_ANALYSIS_CONFIG, + type ImageAnalysisConfig, +} from '../../../../src/config/unified-config-types'; +import { + canonicalizeImageAnalysisConfig, + resolveImageAnalysisStatus, +} from '../../../../src/utils/hooks/image-analysis-backend-resolver'; + +describe('image-analysis-backend-resolver', () => { + it('canonicalizes provider aliases in config', () => { + const config = canonicalizeImageAnalysisConfig({ + enabled: true, + timeout: 60, + provider_models: { + copilot: 'claude-haiku-4.5', + gemini: 'gemini-2.5-flash', + }, + fallback_backend: 'Gemini', + profile_backends: { + orq: 'copilot', + }, + }); + + expect(config.provider_models.ghcp).toBe('claude-haiku-4.5'); + expect(config.provider_models.copilot).toBeUndefined(); + expect(config.fallback_backend).toBe('gemini'); + expect(config.profile_backends?.orq).toBe('ghcp'); + }); + + it('resolves copilot to the ghcp backend without a duplicate provider key', () => { + const status = resolveImageAnalysisStatus( + { + profileName: 'copilot', + profileType: 'copilot', + }, + DEFAULT_IMAGE_ANALYSIS_CONFIG + ); + + expect(status.supported).toBe(true); + expect(status.backendId).toBe('ghcp'); + expect(status.model).toBe('claude-haiku-4.5'); + expect(status.resolutionSource).toBe('copilot-alias'); + }); + + it('uses the fallback backend for an unmapped settings profile', () => { + const status = resolveImageAnalysisStatus( + { + profileName: 'glm', + profileType: 'settings', + }, + DEFAULT_IMAGE_ANALYSIS_CONFIG + ); + + expect(status.supported).toBe(true); + expect(status.backendId).toBe('gemini'); + expect(status.resolutionSource).toBe('fallback-backend'); + expect(status.model).toBe('gemini-2.5-flash'); + }); + + it('uses explicit profile_backends overrides for custom aliases', () => { + const config: ImageAnalysisConfig = { + ...DEFAULT_IMAGE_ANALYSIS_CONFIG, + profile_backends: { + orq: 'copilot', + }, + }; + + const status = resolveImageAnalysisStatus( + { + profileName: 'orq', + profileType: 'settings', + }, + config + ); + + expect(status.supported).toBe(true); + expect(status.status).toBe('mapped'); + expect(status.backendId).toBe('ghcp'); + expect(status.resolutionSource).toBe('profile-backend'); + }); + + it('reports hook-missing when the profile should persist a hook but it is absent', () => { + const status = resolveImageAnalysisStatus( + { + profileName: 'glm', + profileType: 'settings', + hookInstalled: false, + sharedHookInstalled: true, + }, + DEFAULT_IMAGE_ANALYSIS_CONFIG + ); + + expect(status.status).toBe('hook-missing'); + expect(status.reason).toContain('Profile hook is missing'); + }); +}); diff --git a/tests/unit/utils/hooks/image-analyzer-profile-hook-injector.test.ts b/tests/unit/utils/hooks/image-analyzer-profile-hook-injector.test.ts new file mode 100644 index 00000000..96c07f32 --- /dev/null +++ b/tests/unit/utils/hooks/image-analyzer-profile-hook-injector.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + ensureProfileHooks, + getImageAnalysisProfileSettingsPath, + hasImageAnalysisProfileHook, +} from '../../../../src/utils/hooks/image-analyzer-profile-hook-injector'; + +function writeJson(filePath: string, value: Record): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8'); +} + +describe('image-analyzer-profile-hook-injector', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-analyzer-profile-hook-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('persists dotted settings profile hooks into the resolved custom settings path', () => { + const customSettingsPath = path.join(tempHome, 'profiles', 'foo.bar.settings.json'); + writeJson(customSettingsPath, { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_API_KEY: 'glm-test-key', + }, + }); + + const ensured = ensureProfileHooks({ + profileName: 'foo.bar', + profileType: 'settings', + settingsPath: customSettingsPath, + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_API_KEY: 'glm-test-key', + }, + }, + }); + + const defaultSettingsPath = path.join(tempHome, '.ccs', 'foo.bar.settings.json'); + const persisted = JSON.parse(fs.readFileSync(customSettingsPath, 'utf8')) as { + hooks?: { PreToolUse?: Array<{ matcher?: string }> }; + }; + + expect(ensured).toBe(true); + expect(getImageAnalysisProfileSettingsPath('foo.bar', customSettingsPath)).toBe( + customSettingsPath + ); + expect(hasImageAnalysisProfileHook('foo.bar', customSettingsPath)).toBe(true); + expect(fs.existsSync(defaultSettingsPath)).toBe(false); + expect(persisted.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read')).toBe(true); + }); +}); diff --git a/tests/unit/web-server/settings-routes-image-analysis-status.test.ts b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts new file mode 100644 index 00000000..229edb86 --- /dev/null +++ b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts @@ -0,0 +1,186 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Server } from 'http'; +import settingsRoutes from '../../../src/web-server/routes/settings-routes'; + +function writeJson(filePath: string, value: Record): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n'); +} + +function installSharedHook(tempHome: string): string { + const hookPath = path.join(tempHome, '.ccs', 'hooks', 'image-analyzer-transformer.cjs'); + fs.mkdirSync(path.dirname(hookPath), { recursive: true }); + fs.writeFileSync(hookPath, '#!/usr/bin/env node\n', 'utf8'); + return hookPath; +} + +function writeProfileSettings( + tempHome: string, + profileName: string, + env: Record, + settingsPath = path.join(tempHome, '.ccs', `${profileName}.settings.json`) +): string { + const hookPath = installSharedHook(tempHome); + writeJson(settingsPath, { + env, + hooks: { + PreToolUse: [ + { + matcher: 'Read', + hooks: [{ type: 'command', command: `node "${hookPath}"`, timeout: 65000 }], + }, + ], + }, + }); + return settingsPath; +} + +describe('settings-routes image-analysis status', () => { + let server: Server; + let baseUrl = ''; + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/settings', settingsRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-status-routes-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('returns fallback-backed image analysis status for settings profiles', async () => { + writeProfileSettings(tempHome, 'glm', { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_API_KEY: 'glm-test-key', + }); + + const response = await fetch(`${baseUrl}/api/settings/glm/raw`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + imageAnalysisStatus: { + status: string; + backendId: string | null; + resolutionSource: string; + model: string | null; + persistencePath: string | null; + }; + }; + + expect(body.imageAnalysisStatus.status).toBe('active'); + expect(body.imageAnalysisStatus.backendId).toBe('gemini'); + expect(body.imageAnalysisStatus.resolutionSource).toBe('fallback-backend'); + expect(body.imageAnalysisStatus.model).toBe('gemini-2.5-flash'); + expect(body.imageAnalysisStatus.persistencePath).toContain('glm.settings.json'); + }); + + it('returns explicit mapped status for custom aliases', async () => { + writeJson(path.join(tempHome, '.ccs', 'config.yaml'), { + version: 11, + image_analysis: { + enabled: true, + timeout: 60, + provider_models: { + gemini: 'gemini-2.5-flash', + ghcp: 'claude-haiku-4.5', + }, + profile_backends: { + orq: 'copilot', + }, + }, + }); + writeProfileSettings(tempHome, 'orq', { + ANTHROPIC_BASE_URL: 'https://openrouter.ai/api/v1', + ANTHROPIC_API_KEY: 'orq-test-key', + }); + + const response = await fetch(`${baseUrl}/api/settings/orq/raw`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + imageAnalysisStatus: { + status: string; + backendId: string | null; + resolutionSource: string; + model: string | null; + }; + }; + + expect(body.imageAnalysisStatus.status).toBe('mapped'); + expect(body.imageAnalysisStatus.backendId).toBe('ghcp'); + expect(body.imageAnalysisStatus.resolutionSource).toBe('profile-backend'); + expect(body.imageAnalysisStatus.model).toBe('claude-haiku-4.5'); + }); + + it('uses the configured custom settings path for status and persistence diagnostics', async () => { + const customSettingsPath = path.join(tempHome, 'profiles', 'foo.bar.settings.json'); + writeJson(path.join(tempHome, '.ccs', 'config.json'), { + profiles: { + 'foo.bar': customSettingsPath, + }, + }); + writeProfileSettings( + tempHome, + 'foo.bar', + { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_API_KEY: 'glm-test-key', + }, + customSettingsPath + ); + + const response = await fetch(`${baseUrl}/api/settings/foo.bar/raw`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + path: string; + imageAnalysisStatus: { + persistencePath: string | null; + hookInstalled: boolean | null; + }; + }; + + expect(body.path).toBe(customSettingsPath); + expect(body.imageAnalysisStatus.persistencePath).toBe(customSettingsPath); + expect(body.imageAnalysisStatus.hookInstalled).toBe(true); + }); +}); diff --git a/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx b/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx new file mode 100644 index 00000000..dfb07934 --- /dev/null +++ b/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { ImageAnalysisStatusSection } from '@/components/profiles/editor/image-analysis-status-section'; +import type { ImageAnalysisStatus } from '@/lib/api-client'; + +function createStatus(overrides: Partial = {}): ImageAnalysisStatus { + return { + enabled: true, + supported: true, + status: 'active', + backendId: 'gemini', + backendDisplayName: 'Google Gemini', + model: 'gemini-2.5-flash', + resolutionSource: 'cliproxy-bridge', + reason: null, + shouldPersistHook: true, + persistencePath: '/tmp/.ccs/glm.settings.json', + runtimePath: '/api/provider/gemini', + usesCurrentTarget: true, + usesCurrentAuthToken: true, + hookInstalled: true, + sharedHookInstalled: true, + ...overrides, + }; +} + +describe('ImageAnalysisStatusSection', () => { + it('renders active bridge diagnostics near the raw editor footer stack', () => { + render(); + + expect(screen.getByText('Image-analysis backend')).toBeInTheDocument(); + expect( + screen.getByText( + /Derived runtime status\. This section is not written into the JSON editor above\./i + ) + ).toBeInTheDocument(); + expect(screen.getByText('Ready')).toBeInTheDocument(); + expect(screen.getByText(/Ready via Google Gemini\./i)).toBeInTheDocument(); + expect(screen.getByText('Google Gemini')).toBeInTheDocument(); + expect(screen.getByTitle(/\/api\/provider\/gemini/)).toBeInTheDocument(); + expect(screen.getByText('gemini-2.5-flash')).toBeInTheDocument(); + }); + + it('renders mapped status and the explicit mapping explanation', () => { + render( + + ); + + expect(screen.getByText('Saved mapping')).toBeInTheDocument(); + expect( + screen.getByText(/Ready via saved GitHub Copilot \(OAuth\) mapping/i) + ).toBeInTheDocument(); + expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument(); + expect(screen.getByText('claude-haiku-4.5')).toBeInTheDocument(); + }); +});