import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn, } 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'; describe('web-server claude-extension-routes', () => { let server: Server; let baseUrl = ''; let tempHome = ''; let originalCcsHome: string | undefined; let originalClaudeConfigDir: string | undefined; let setGlobalConfigDir: (dir: string | undefined) => void; let claudeExtensionRoutes: ReturnType; let SharedManager: typeof import('../../../src/management/shared-manager').default; let createEmptyUnifiedConfig: typeof import('../../../src/config/unified-config-types').createEmptyUnifiedConfig; let saveUnifiedConfig: typeof import('../../../src/config/unified-config-loader').saveUnifiedConfig; beforeAll(async () => { originalCcsHome = process.env.CCS_HOME; originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; ({ setGlobalConfigDir } = await import('../../../src/utils/config-manager')); ({ default: SharedManager } = await import('../../../src/management/shared-manager')); ({ createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types')); ({ saveUnifiedConfig } = await import('../../../src/config/unified-config-loader')); ({ default: claudeExtensionRoutes } = await import( '../../../src/web-server/routes/claude-extension-routes' )); const app = express(); app.use(express.json()); app.use('/api/claude-extension', claudeExtensionRoutes); await new Promise((resolve, reject) => { server = app.listen(0, '127.0.0.1'); const handleError = (error: Error) => reject(error); server.once('error', handleError); server.once('listening', () => { server.off('error', handleError); 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 () => { if (server) { await new Promise((resolve) => server.close(() => resolve())); } setGlobalConfigDir(undefined); if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; else delete process.env.CCS_HOME; if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; else delete process.env.CLAUDE_CONFIG_DIR; }); beforeEach(() => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-claude-extension-routes-')); process.env.CCS_HOME = tempHome; process.env.CLAUDE_CONFIG_DIR = path.join(tempHome, '.claude'); setGlobalConfigDir(path.join(tempHome, '.ccs')); const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); fs.writeFileSync( path.join(ccsDir, 'glm.settings.json'), JSON.stringify( { env: { ANTHROPIC_BASE_URL: 'https://api.example.test', ANTHROPIC_API_KEY: 'sk-ant-test-123456', ANTHROPIC_MODEL: 'claude-sonnet-4-5', }, }, null, 2 ) + '\n' ); fs.writeFileSync( path.join(ccsDir, 'rich.settings.json'), JSON.stringify( { env: { ANTHROPIC_BASE_URL: 'https://rich.example.test', ANTHROPIC_API_KEY: 'sk-ant-rich-123456', ANTHROPIC_MODEL: 'claude-opus-4-1', ANTHROPIC_MAX_TOKENS: '65536', API_TIMEOUT_MS: '120000', EXPERIMENTAL_ROUTER_HEADER: 'tenant-alpha', }, }, null, 2 ) + '\n' ); const config = createEmptyUnifiedConfig(); config.profiles.glm = { type: 'api', settings: path.join(ccsDir, 'glm.settings.json'), }; config.profiles.rich = { type: 'api', settings: path.join(ccsDir, 'rich.settings.json'), }; config.accounts.work = { created: '2026-03-15T00:00:00.000Z', last_used: null, context_mode: 'isolated', }; config.default = 'work'; saveUnifiedConfig(config); }); afterEach(() => { mock.restore(); setGlobalConfigDir(undefined); delete process.env.CCS_HOME; delete process.env.CLAUDE_CONFIG_DIR; if (tempHome && fs.existsSync(tempHome)) { fs.rmSync(tempHome, { recursive: true, force: true }); } }); it('lists profile options and IDE host targets', async () => { const response = await fetch(`${baseUrl}/api/claude-extension/profiles`); expect(response.status).toBe(200); const payload = (await response.json()) as { profiles: Array<{ name: string }>; hosts: Array<{ id: string; defaultSettingsPath: string }>; }; expect(payload.profiles.some((profile) => profile.name === 'default')).toBe(true); expect(payload.profiles.some((profile) => profile.name === 'glm')).toBe(true); expect(payload.profiles.some((profile) => profile.name === 'work')).toBe(true); expect(payload.profiles.some((profile) => profile.name === 'gemini')).toBe(true); expect(payload.hosts.map((host) => host.id)).toEqual(['vscode', 'cursor', 'windsurf']); expect(payload.hosts.every((host) => host.defaultSettingsPath.endsWith('settings.json'))).toBe( true ); }); it('renders VS Code setup for API profiles with disableLoginPrompt', async () => { const response = await fetch(`${baseUrl}/api/claude-extension/setup?profile=glm&host=vscode`); expect(response.status).toBe(200); const payload = (await response.json()) as { host: { settingsKey: string; disableLoginPromptKey?: string }; ideSettings: { json: string }; sharedSettings: { command: string; json: string }; }; expect(payload.host.settingsKey).toBe('claudeCode.environmentVariables'); expect(payload.host.disableLoginPromptKey).toBe('claudeCode.disableLoginPrompt'); expect(payload.ideSettings.json).toContain('"claudeCode.disableLoginPrompt": true'); expect(payload.ideSettings.json).toContain('"ANTHROPIC_API_KEY"'); expect(payload.sharedSettings.command).toBe('ccs persist glm'); expect(payload.sharedSettings.json).toContain('"env"'); }); it('normalizes the effective profile CLAUDE_CONFIG_DIR for extension setup', async () => { const explicitConfigDir = path.join(tempHome, '.claude-profiles', 'glm'); const glmSettingsPath = path.join(tempHome, '.ccs', 'glm.settings.json'); const normalizeSpy = spyOn( SharedManager.prototype, 'normalizeSharedPluginMetadataPaths' ).mockImplementation(() => {}); fs.writeFileSync( glmSettingsPath, JSON.stringify( { env: { ANTHROPIC_BASE_URL: 'https://api.example.test', ANTHROPIC_API_KEY: 'sk-ant-test-123456', ANTHROPIC_MODEL: 'claude-sonnet-4-5', CLAUDE_CONFIG_DIR: explicitConfigDir, }, }, null, 2 ) + '\n' ); const config = createEmptyUnifiedConfig(); config.profiles.glm = { type: 'api', settings: glmSettingsPath, }; config.accounts.work = { created: '2026-03-15T00:00:00.000Z', last_used: null, context_mode: 'isolated', }; config.default = 'work'; config.continuity = { inherit_from_account: { glm: 'work', }, }; saveUnifiedConfig(config); const response = await fetch(`${baseUrl}/api/claude-extension/setup?profile=glm&host=vscode`); expect(response.status).toBe(200); const payload = (await response.json()) as { ideSettings: { json: string }; }; expect(payload.ideSettings.json).toContain(explicitConfigDir); expect(normalizeSpy.mock.calls.some(([configDir]) => configDir === explicitConfigDir)).toBe( true ); }); it('renders Windsurf setup for default account resolution via CLAUDE_CONFIG_DIR', async () => { const response = await fetch( `${baseUrl}/api/claude-extension/setup?profile=default&host=windsurf` ); expect(response.status).toBe(200); const payload = (await response.json()) as { profile: { profileType: string; resolvedProfileName: string }; host: { settingsKey: string }; ideSettings: { json: string }; sharedSettings: { command: string }; }; expect(payload.profile.profileType).toBe('account'); expect(payload.profile.resolvedProfileName).toBe('work'); expect(payload.host.settingsKey).toBe('claude-code.environmentVariables'); expect(payload.ideSettings.json).toContain('"claude-code.environmentVariables"'); expect(payload.ideSettings.json).toContain('"CLAUDE_CONFIG_DIR"'); expect(payload.sharedSettings.command).toBe('ccs persist default'); }); it('creates a binding and applies managed settings to shared + IDE targets', async () => { const ideSettingsPath = path.join(tempHome, 'ide', 'vscode', 'settings.json'); const createResponse = await fetch(`${baseUrl}/api/claude-extension/bindings`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'GLM in VS Code', profile: 'glm', host: 'vscode', ideSettingsPath, }), }); expect(createResponse.status).toBe(201); const created = (await createResponse.json()) as { binding: { id: string; effectiveIdeSettingsPath: string; usesDefaultIdeSettingsPath: boolean; }; }; expect(created.binding.effectiveIdeSettingsPath).toBe(ideSettingsPath); expect(created.binding.usesDefaultIdeSettingsPath).toBe(false); const applyResponse = await fetch( `${baseUrl}/api/claude-extension/bindings/${created.binding.id}/apply`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'all' }), } ); expect(applyResponse.status).toBe(200); const applied = (await applyResponse.json()) as { sharedSettings: { state: string }; ideSettings: { state: string }; }; expect(applied.sharedSettings.state).toBe('applied'); expect(applied.ideSettings.state).toBe('applied'); const sharedSettingsPath = path.join(tempHome, '.claude', 'settings.json'); const sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf8')) as { env?: Record; }; const ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record< string, unknown >; expect(sharedSettings.env?.ANTHROPIC_API_KEY).toBe('sk-ant-test-123456'); expect(sharedSettings.env?.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5'); expect( Array.isArray(ideSettings['claudeCode.environmentVariables']) && (ideSettings['claudeCode.environmentVariables'] as Array<{ name: string }>).some( (entry) => entry.name === 'ANTHROPIC_API_KEY' ) ).toBe(true); expect(ideSettings['claudeCode.disableLoginPrompt']).toBe(true); }); it('resets only managed keys and preserves unrelated shared + IDE settings', async () => { const ideSettingsPath = path.join(tempHome, 'ide', 'cursor', 'settings.json'); fs.mkdirSync(path.dirname(ideSettingsPath), { recursive: true }); fs.mkdirSync(path.join(tempHome, '.claude'), { recursive: true }); fs.writeFileSync( path.join(tempHome, '.claude', 'settings.json'), JSON.stringify( { theme: 'dark', env: { KEEP_ME: '1', ANTHROPIC_API_KEY: 'stale', }, }, null, 2 ) + '\n' ); fs.writeFileSync( ideSettingsPath, JSON.stringify( { 'editor.fontSize': 14, 'claudeCode.environmentVariables': [{ name: 'ANTHROPIC_API_KEY', value: 'stale' }], 'claudeCode.disableLoginPrompt': true, }, null, 2 ) + '\n' ); const createResponse = await fetch(`${baseUrl}/api/claude-extension/bindings`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Cursor reset', profile: 'glm', host: 'cursor', ideSettingsPath, }), }); const created = (await createResponse.json()) as { binding: { id: string } }; const resetResponse = await fetch( `${baseUrl}/api/claude-extension/bindings/${created.binding.id}/reset`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'all' }), } ); expect(resetResponse.status).toBe(200); const resetPayload = (await resetResponse.json()) as { sharedSettings: { state: string }; ideSettings: { state: string }; }; expect(resetPayload.sharedSettings.state).toBe('unconfigured'); expect(resetPayload.ideSettings.state).toBe('unconfigured'); const sharedSettings = JSON.parse( fs.readFileSync(path.join(tempHome, '.claude', 'settings.json'), 'utf8') ) as { theme?: string; env?: Record; }; const ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record< string, unknown >; expect(sharedSettings.theme).toBe('dark'); expect(sharedSettings.env?.KEEP_ME).toBe('1'); expect(sharedSettings.env?.ANTHROPIC_API_KEY).toBeUndefined(); expect(ideSettings['editor.fontSize']).toBe(14); expect(ideSettings['claudeCode.environmentVariables']).toBeUndefined(); expect(ideSettings['claudeCode.disableLoginPrompt']).toBeUndefined(); }); it('removes optional shared env keys even after the profile payload shrinks', async () => { const ideSettingsPath = path.join(tempHome, 'ide', 'vscode', 'settings.json'); const createResponse = await fetch(`${baseUrl}/api/claude-extension/bindings`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Rich env binding', profile: 'rich', host: 'vscode', ideSettingsPath, }), }); const created = (await createResponse.json()) as { binding: { id: string } }; const applyResponse = await fetch( `${baseUrl}/api/claude-extension/bindings/${created.binding.id}/apply`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'shared' }), } ); expect(applyResponse.status).toBe(200); const sharedSettingsPath = path.join(tempHome, '.claude', 'settings.json'); let sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf8')) as { env?: Record; }; expect(sharedSettings.env?.ANTHROPIC_API_KEY).toBe('sk-ant-rich-123456'); expect(sharedSettings.env?.ANTHROPIC_MAX_TOKENS).toBe('65536'); expect(sharedSettings.env?.API_TIMEOUT_MS).toBe('120000'); expect(sharedSettings.env?.EXPERIMENTAL_ROUTER_HEADER).toBe('tenant-alpha'); fs.writeFileSync( path.join(tempHome, '.ccs', 'rich.settings.json'), JSON.stringify( { env: { ANTHROPIC_BASE_URL: 'https://rich.example.test', ANTHROPIC_API_KEY: 'sk-ant-rich-123456', ANTHROPIC_MODEL: 'claude-opus-4-1', }, }, null, 2 ) + '\n' ); const resetResponse = await fetch( `${baseUrl}/api/claude-extension/bindings/${created.binding.id}/reset`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'shared' }), } ); expect(resetResponse.status).toBe(200); const verifyResponse = await fetch( `${baseUrl}/api/claude-extension/bindings/${created.binding.id}/verify` ); expect(verifyResponse.status).toBe(200); const verified = (await verifyResponse.json()) as { sharedSettings: { state: string }; }; expect(verified.sharedSettings.state).toBe('unconfigured'); sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf8')) as { env?: Record; }; expect(sharedSettings.env?.ANTHROPIC_API_KEY).toBeUndefined(); expect(sharedSettings.env?.ANTHROPIC_MAX_TOKENS).toBeUndefined(); expect(sharedSettings.env?.API_TIMEOUT_MS).toBeUndefined(); expect(sharedSettings.env?.EXPERIMENTAL_ROUTER_HEADER).toBeUndefined(); }); it('preserves unrelated IDE env entries while applying and resetting managed values', async () => { const ideSettingsPath = path.join(tempHome, 'ide', 'vscode', 'settings.json'); fs.mkdirSync(path.dirname(ideSettingsPath), { recursive: true }); fs.writeFileSync( ideSettingsPath, `{ // VS Code stores JSONC here, not strict JSON "editor.tabSize": 2, "claudeCode.environmentVariables": [ { "name": "KEEP_ME", "value": "1" }, { "name": "ANTHROPIC_API_KEY", "value": "stale" }, ], "claudeCode.disableLoginPrompt": false, } ` ); const createResponse = await fetch(`${baseUrl}/api/claude-extension/bindings`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'VS Code preserved env', profile: 'glm', host: 'vscode', ideSettingsPath, }), }); const created = (await createResponse.json()) as { binding: { id: string } }; const applyResponse = await fetch( `${baseUrl}/api/claude-extension/bindings/${created.binding.id}/apply`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'ide' }), } ); expect(applyResponse.status).toBe(200); let ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record< string, unknown >; const appliedEnv = ideSettings['claudeCode.environmentVariables'] as Array<{ name: string; value: string; }>; expect(appliedEnv.some((entry) => entry.name === 'KEEP_ME' && entry.value === '1')).toBe(true); expect( appliedEnv.some( (entry) => entry.name === 'ANTHROPIC_API_KEY' && entry.value === 'sk-ant-test-123456' ) ).toBe(true); const verifyAppliedResponse = await fetch( `${baseUrl}/api/claude-extension/bindings/${created.binding.id}/verify` ); expect(verifyAppliedResponse.status).toBe(200); const verifiedApplied = (await verifyAppliedResponse.json()) as { ideSettings: { state: string }; }; expect(verifiedApplied.ideSettings.state).toBe('applied'); const resetResponse = await fetch( `${baseUrl}/api/claude-extension/bindings/${created.binding.id}/reset`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'ide' }), } ); expect(resetResponse.status).toBe(200); ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record; expect(ideSettings['editor.tabSize']).toBe(2); expect(ideSettings['claudeCode.disableLoginPrompt']).toBeUndefined(); expect(ideSettings['claudeCode.environmentVariables']).toEqual([ { name: 'KEEP_ME', value: '1' }, ]); const verifyResetResponse = await fetch( `${baseUrl}/api/claude-extension/bindings/${created.binding.id}/verify` ); expect(verifyResetResponse.status).toBe(200); const verifiedReset = (await verifyResetResponse.json()) as { ideSettings: { state: string }; }; expect(verifiedReset.ideSettings.state).toBe('unconfigured'); }); it('rejects bindings for profiles that do not exist', async () => { const response = await fetch(`${baseUrl}/api/claude-extension/bindings`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Broken profile binding', profile: 'does-not-exist', host: 'vscode', }), }); expect(response.status).toBe(400); const payload = (await response.json()) as { error: string }; expect(payload.error).toContain('Unknown profile'); }); });