import { PassThrough } from 'node:stream' import { afterEach, expect, mock, test } from 'bun:test' import React from 'react' import stripAnsi from 'strip-ansi' import { createRoot } from '../ink.js' import { AppStateProvider } from '../state/AppState.js' import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js' const SYNC_START = '\x1B[?2026h' const SYNC_END = '\x1B[?2026l' const ORIGINAL_ENV = { CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB, GITHUB_TOKEN: process.env.GITHUB_TOKEN, GH_TOKEN: process.env.GH_TOKEN, } function extractLastFrame(output: string): string { let lastFrame: string | null = null let cursor = 0 while (cursor < output.length) { const start = output.indexOf(SYNC_START, cursor) if (start === -1) { break } const contentStart = start + SYNC_START.length const end = output.indexOf(SYNC_END, contentStart) if (end === -1) { break } const frame = output.slice(contentStart, end) if (frame.trim().length > 0) { lastFrame = frame } cursor = end + SYNC_END.length } return lastFrame ?? output } function createTestStreams(): { stdout: PassThrough stdin: PassThrough & { isTTY: boolean setRawMode: (mode: boolean) => void ref: () => void unref: () => void } getOutput: () => string } { let output = '' const stdout = new PassThrough() const stdin = new PassThrough() as PassThrough & { isTTY: boolean setRawMode: (mode: boolean) => void ref: () => void unref: () => void } stdin.isTTY = true stdin.setRawMode = () => {} stdin.ref = () => {} stdin.unref = () => {} ;(stdout as unknown as { columns: number }).columns = 120 stdout.on('data', chunk => { output += chunk.toString() }) return { stdout, stdin, getOutput: () => output, } } async function waitForCondition( predicate: () => boolean, options?: { timeoutMs?: number; intervalMs?: number }, ): Promise { const timeoutMs = options?.timeoutMs ?? 2000 const intervalMs = options?.intervalMs ?? 10 const startedAt = Date.now() while (Date.now() - startedAt < timeoutMs) { if (predicate()) { return } await Bun.sleep(intervalMs) } throw new Error('Timed out waiting for ProviderManager test condition') } function createDeferred(): { promise: Promise resolve: (value: T) => void } { let resolve!: (value: T) => void const promise = new Promise(r => { resolve = r }) return { promise, resolve } } function mockProviderProfilesModule(options?: { addProviderProfile?: (...args: unknown[]) => unknown }): void { mock.module('../utils/providerProfiles.js', () => ({ addProviderProfile: options?.addProviderProfile ?? (() => null), applyActiveProviderProfileFromConfig: () => {}, deleteProviderProfile: () => ({ removed: false, activeProfileId: null }), getActiveProviderProfile: () => null, getProviderPresetDefaults: (preset: string) => preset === 'ollama' ? { provider: 'openai', name: 'Ollama', baseUrl: 'http://localhost:11434/v1', model: 'llama3.1:8b', apiKey: '', } : { provider: 'openai', name: 'Mock provider', baseUrl: 'http://localhost:11434/v1', model: 'mock-model', apiKey: '', }, getProviderProfiles: () => [], setActiveProviderProfile: () => null, updateProviderProfile: () => null, })) } function mockProviderManagerDependencies( syncRead: () => string | undefined, asyncRead: () => Promise, options?: { addProviderProfile?: (...args: unknown[]) => unknown hasLocalOllama?: () => Promise listOllamaModels?: () => Promise< Array<{ name: string sizeBytes?: number | null family?: string | null families?: string[] parameterSize?: string | null quantizationLevel?: string | null }> > }, ): void { mockProviderProfilesModule({ addProviderProfile: options?.addProviderProfile }) mock.module('../utils/providerDiscovery.js', () => ({ hasLocalOllama: options?.hasLocalOllama ?? (async () => false), listOllamaModels: options?.listOllamaModels ?? (async () => []), })) mock.module('../utils/githubModelsCredentials.js', () => ({ clearGithubModelsToken: () => ({ success: true }), GITHUB_MODELS_HYDRATED_ENV_MARKER: 'CLAUDE_CODE_GITHUB_TOKEN_HYDRATED', hydrateGithubModelsTokenFromSecureStorage: () => {}, readGithubModelsToken: syncRead, readGithubModelsTokenAsync: asyncRead, })) mock.module('../utils/settings/settings.js', () => ({ updateSettingsForSource: () => ({ error: null }), })) } async function waitForFrameOutput( getOutput: () => string, predicate: (output: string) => boolean, timeoutMs = 2500, ): Promise { let output = '' await waitForCondition(() => { output = stripAnsi(extractLastFrame(getOutput())) return predicate(output) }, { timeoutMs }) return output } async function mountProviderManager( ProviderManager: React.ComponentType<{ mode: 'first-run' | 'manage' onDone: (result?: unknown) => void }>, options?: { mode?: 'first-run' | 'manage' onDone?: (result?: unknown) => void }, ): Promise<{ stdin: PassThrough getOutput: () => string dispose: () => Promise }> { const { stdout, stdin, getOutput } = createTestStreams() const root = await createRoot({ stdout: stdout as unknown as NodeJS.WriteStream, stdin: stdin as unknown as NodeJS.ReadStream, patchConsole: false, }) root.render( {})} /> , ) return { stdin, getOutput, dispose: async () => { root.unmount() stdin.end() stdout.end() await Bun.sleep(0) }, } } async function renderProviderManagerFrame( ProviderManager: React.ComponentType<{ mode: 'first-run' | 'manage' onDone: (result?: unknown) => void }>, options?: { waitForOutput?: (output: string) => boolean timeoutMs?: number mode?: 'first-run' | 'manage' }, ): Promise { const mounted = await mountProviderManager(ProviderManager, { mode: options?.mode, }) const output = await waitForFrameOutput( mounted.getOutput, frame => { if (!options?.waitForOutput) { return frame.includes('Provider manager') } return options.waitForOutput(frame) }, options?.timeoutMs ?? 2500, ) await mounted.dispose() return output } afterEach(() => { mock.restore() for (const [key, value] of Object.entries(ORIGINAL_ENV)) { if (value === undefined) { delete process.env[key as keyof typeof ORIGINAL_ENV] } else { process.env[key as keyof typeof ORIGINAL_ENV] = value } } }) test('ProviderManager resolves GitHub virtual provider from async storage without sync reads in render flow', async () => { delete process.env.CLAUDE_CODE_USE_GITHUB delete process.env.GITHUB_TOKEN delete process.env.GH_TOKEN const syncRead = mock(() => { throw new Error('sync credential read should not run in ProviderManager render flow') }) const asyncRead = mock(async () => 'stored-token') mockProviderManagerDependencies(syncRead, asyncRead) const nonce = `${Date.now()}-${Math.random()}` const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`) const output = await renderProviderManagerFrame(ProviderManager, { waitForOutput: frame => frame.includes('Provider manager') && frame.includes('GitHub Models') && frame.includes('token stored'), }) expect(output).toContain('Provider manager') expect(output).toContain('GitHub Models') expect(output).toContain('token stored') expect(output).not.toContain('No provider profiles configured yet.') expect(syncRead).not.toHaveBeenCalled() expect(asyncRead).toHaveBeenCalled() }) test('ProviderManager first-run Ollama preset auto-detects installed models', async () => { delete process.env.CLAUDE_CODE_USE_GITHUB delete process.env.GITHUB_TOKEN delete process.env.GH_TOKEN const onDone = mock(() => {}) const addProviderProfile = mock((payload: { provider: string name: string baseUrl: string model: string apiKey?: string }) => ({ id: 'provider_ollama', provider: payload.provider, name: payload.name, baseUrl: payload.baseUrl, model: payload.model, apiKey: payload.apiKey, })) mockProviderManagerDependencies( () => undefined, async () => undefined, { addProviderProfile, hasLocalOllama: async () => true, listOllamaModels: async () => [ { name: 'gemma4:31b-cloud', family: 'gemma', parameterSize: '31b', }, { name: 'kimi-k2.5:cloud', family: 'kimi', parameterSize: '2.5b', }, ], }, ) const nonce = `${Date.now()}-${Math.random()}` const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`) const mounted = await mountProviderManager(ProviderManager, { mode: 'first-run', onDone, }) await waitForFrameOutput( mounted.getOutput, frame => frame.includes('Set up provider') && frame.includes('Ollama'), ) mounted.stdin.write('j') await Bun.sleep(50) mounted.stdin.write('\r') const modelFrame = await waitForFrameOutput( mounted.getOutput, frame => frame.includes('Choose an Ollama model') && frame.includes('gemma4:31b-cloud') && frame.includes('kimi-k2.5:cloud'), ) expect(modelFrame).toContain('Choose an Ollama model') expect(modelFrame).toContain('gemma4:31b-cloud') await Bun.sleep(25) mounted.stdin.write('\r') await waitForCondition(() => onDone.mock.calls.length > 0) expect(addProviderProfile).toHaveBeenCalled() expect(addProviderProfile.mock.calls[0]?.[0]).toMatchObject({ name: 'Ollama', baseUrl: 'http://localhost:11434/v1', model: 'gemma4:31b-cloud', }) expect(onDone).toHaveBeenCalledWith( expect.objectContaining({ action: 'saved', message: 'Provider configured: Ollama', }), ) await mounted.dispose() }) test('ProviderManager avoids first-frame false negative while stored-token lookup is pending', async () => { delete process.env.CLAUDE_CODE_USE_GITHUB delete process.env.GITHUB_TOKEN delete process.env.GH_TOKEN const syncRead = mock(() => { throw new Error('sync credential read should not run in ProviderManager render flow') }) const deferredStoredToken = createDeferred() const asyncRead = mock(async () => deferredStoredToken.promise) mockProviderManagerDependencies(syncRead, asyncRead) const nonce = `${Date.now()}-${Math.random()}` const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`) const mounted = await mountProviderManager(ProviderManager) const firstFrame = await waitForFrameOutput( mounted.getOutput, frame => frame.includes('Provider manager'), ) expect(firstFrame).toContain('Checking GitHub Models credentials...') expect(firstFrame).not.toContain('No provider profiles configured yet.') deferredStoredToken.resolve('stored-token') const resolvedFrame = await waitForFrameOutput( mounted.getOutput, frame => frame.includes('GitHub Models') && frame.includes('token stored'), ) expect(resolvedFrame).toContain('GitHub Models') expect(resolvedFrame).toContain('token stored') await mounted.dispose() expect(syncRead).not.toHaveBeenCalled() expect(asyncRead).toHaveBeenCalled() })