diff --git a/src/components/ProviderManager.test.tsx b/src/components/ProviderManager.test.tsx new file mode 100644 index 00000000..5af5ebc8 --- /dev/null +++ b/src/components/ProviderManager.test.tsx @@ -0,0 +1,305 @@ +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' + +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(): void { + mock.module('../utils/providerProfiles.js', () => ({ + addProviderProfile: () => null, + applyActiveProviderProfileFromConfig: () => {}, + deleteProviderProfile: () => ({ removed: false, activeProfileId: null }), + getActiveProviderProfile: () => null, + getProviderPresetDefaults: () => ({ + 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, +): void { + mockProviderProfilesModule() + + 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: () => void + }>, +): Promise<{ + 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 { + getOutput, + dispose: async () => { + root.unmount() + stdin.end() + stdout.end() + await Bun.sleep(0) + }, + } +} + +async function renderProviderManagerFrame( + ProviderManager: React.ComponentType<{ + mode: 'first-run' | 'manage' + onDone: () => void + }>, + options?: { + waitForOutput?: (output: string) => boolean + timeoutMs?: number + }, +): Promise { + const mounted = await mountProviderManager(ProviderManager) + 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 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() +}) diff --git a/src/components/ProviderManager.tsx b/src/components/ProviderManager.tsx index 65622d8e..31b8752d 100644 --- a/src/components/ProviderManager.tsx +++ b/src/components/ProviderManager.tsx @@ -20,6 +20,7 @@ import { GITHUB_MODELS_HYDRATED_ENV_MARKER, hydrateGithubModelsTokenFromSecureStorage, readGithubModelsToken, + readGithubModelsTokenAsync, } from '../utils/githubModelsCredentials.js' import { isEnvTruthy } from '../utils/envUtils.js' import { updateSettingsForSource } from '../utils/settings/settings.js' @@ -118,25 +119,38 @@ function profileSummary(profile: ProviderProfile, isActive: boolean): string { return `${providerKind} · ${profile.baseUrl} · ${profile.model} · ${keyInfo}${activeSuffix}` } -function getGithubCredentialSource( +function getGithubCredentialSourceFromEnv( processEnv: NodeJS.ProcessEnv = process.env, ): GithubCredentialSource { - if (readGithubModelsToken()?.trim()) { - return 'stored' - } if (processEnv.GITHUB_TOKEN?.trim() || processEnv.GH_TOKEN?.trim()) { return 'env' } return 'none' } +async function resolveGithubCredentialSource( + processEnv: NodeJS.ProcessEnv = process.env, +): Promise { + const envSource = getGithubCredentialSourceFromEnv(processEnv) + if (envSource !== 'none') { + return envSource + } + + if (await readGithubModelsTokenAsync()) { + return 'stored' + } + + return 'none' +} + function isGithubProviderAvailable( + credentialSource: GithubCredentialSource, processEnv: NodeJS.ProcessEnv = process.env, ): boolean { if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB)) { return true } - return getGithubCredentialSource(processEnv) !== 'none' + return credentialSource !== 'none' } function getGithubProviderModel( @@ -164,19 +178,24 @@ function getGithubProviderSummary( } export function ProviderManager({ mode, onDone }: Props): React.ReactNode { + const initialGithubCredentialSource = getGithubCredentialSourceFromEnv() + const initialIsGithubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) + const initialHasGithubCredential = initialGithubCredentialSource !== 'none' + const [profiles, setProfiles] = React.useState(() => getProviderProfiles()) const [activeProfileId, setActiveProfileId] = React.useState( () => getActiveProviderProfile()?.id, ) - const [githubProviderAvailable, setGithubProviderAvailable] = React.useState(() => - isGithubProviderAvailable(), + const [githubProviderAvailable, setGithubProviderAvailable] = React.useState( + () => isGithubProviderAvailable(initialGithubCredentialSource), ) const [githubCredentialSource, setGithubCredentialSource] = React.useState( - () => getGithubCredentialSource(), - ) - const [isGithubActive, setIsGithubActive] = React.useState(() => - isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB), + () => initialGithubCredentialSource, ) + const [isGithubActive, setIsGithubActive] = React.useState(() => initialIsGithubActive) + const [isGithubCredentialSourceResolved, setIsGithubCredentialSourceResolved] = + React.useState(() => initialHasGithubCredential || initialIsGithubActive) + const githubRefreshEpochRef = React.useRef(0) const [screen, setScreen] = React.useState( mode === 'first-run' ? 'select-preset' : 'menu', ) @@ -196,13 +215,48 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { const currentStepKey = currentStep.key const currentValue = draft[currentStepKey] + const refreshGithubProviderState = React.useCallback((): void => { + const envCredentialSource = getGithubCredentialSourceFromEnv() + const githubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) + const canResolveFromEnv = githubActive || envCredentialSource !== 'none' + + if (canResolveFromEnv) { + githubRefreshEpochRef.current += 1 + setGithubCredentialSource(envCredentialSource) + setGithubProviderAvailable(isGithubProviderAvailable(envCredentialSource)) + setIsGithubActive(githubActive) + setIsGithubCredentialSourceResolved(true) + return + } + + setIsGithubCredentialSourceResolved(false) + const refreshEpoch = ++githubRefreshEpochRef.current + void (async () => { + const credentialSource = await resolveGithubCredentialSource() + if (refreshEpoch !== githubRefreshEpochRef.current) { + return + } + + setGithubCredentialSource(credentialSource) + setGithubProviderAvailable(isGithubProviderAvailable(credentialSource)) + setIsGithubActive(isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)) + setIsGithubCredentialSourceResolved(true) + })() + }, []) + + React.useEffect(() => { + refreshGithubProviderState() + + return () => { + githubRefreshEpochRef.current += 1 + } + }, [refreshGithubProviderState]) + function refreshProfiles(): void { const nextProfiles = getProviderProfiles() setProfiles(nextProfiles) setActiveProfileId(getActiveProviderProfile()?.id) - setGithubProviderAvailable(isGithubProviderAvailable()) - setGithubCredentialSource(getGithubCredentialSource()) - setIsGithubActive(isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)) + refreshGithubProviderState() } function clearStartupProviderOverrideFromUserSettings(): string | null { @@ -640,7 +694,11 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { {statusMessage && {statusMessage}} {profiles.length === 0 && !githubProviderAvailable ? ( - No provider profiles configured yet. + isGithubCredentialSourceResolved ? ( + No provider profiles configured yet. + ) : ( + Checking GitHub Models credentials... + ) ) : ( <> {profiles.map(profile => ( diff --git a/src/utils/githubModelsCredentials.hydrate.test.ts b/src/utils/githubModelsCredentials.hydrate.test.ts index 4602bc65..fd0d257d 100644 --- a/src/utils/githubModelsCredentials.hydrate.test.ts +++ b/src/utils/githubModelsCredentials.hydrate.test.ts @@ -41,7 +41,7 @@ describe('hydrateGithubModelsTokenFromSecureStorage', () => { })) const { hydrateGithubModelsTokenFromSecureStorage } = await import( - './githubModelsCredentials.js' + './githubModelsCredentials.js?hydrate=sets-token' ) hydrateGithubModelsTokenFromSecureStorage() expect(process.env.GITHUB_TOKEN).toBe('stored-secret') @@ -62,7 +62,7 @@ describe('hydrateGithubModelsTokenFromSecureStorage', () => { })) const { hydrateGithubModelsTokenFromSecureStorage } = await import( - './githubModelsCredentials.js' + './githubModelsCredentials.js?hydrate=preserve-existing' ) hydrateGithubModelsTokenFromSecureStorage() expect(process.env.GITHUB_TOKEN).toBe('already') diff --git a/src/utils/githubModelsCredentials.test.ts b/src/utils/githubModelsCredentials.test.ts index 81c3cdcc..c31926e5 100644 --- a/src/utils/githubModelsCredentials.test.ts +++ b/src/utils/githubModelsCredentials.test.ts @@ -1,13 +1,11 @@ import { describe, expect, test } from 'bun:test' -import { - clearGithubModelsToken, - readGithubModelsToken, - saveGithubModelsToken, -} from './githubModelsCredentials.js' - describe('readGithubModelsToken', () => { - test('returns undefined in bare mode', () => { + test('returns undefined in bare mode', async () => { + const { readGithubModelsToken } = await import( + './githubModelsCredentials.js?read-bare-mode' + ) + const prev = process.env.CLAUDE_CODE_SIMPLE process.env.CLAUDE_CODE_SIMPLE = '1' expect(readGithubModelsToken()).toBeUndefined() @@ -20,7 +18,11 @@ describe('readGithubModelsToken', () => { }) describe('saveGithubModelsToken / clearGithubModelsToken', () => { - test('save returns failure in bare mode', () => { + test('save returns failure in bare mode', async () => { + const { saveGithubModelsToken } = await import( + './githubModelsCredentials.js?save-bare-mode' + ) + const prev = process.env.CLAUDE_CODE_SIMPLE process.env.CLAUDE_CODE_SIMPLE = '1' const r = saveGithubModelsToken('abc') @@ -33,7 +35,11 @@ describe('saveGithubModelsToken / clearGithubModelsToken', () => { } }) - test('clear succeeds in bare mode', () => { + test('clear succeeds in bare mode', async () => { + const { clearGithubModelsToken } = await import( + './githubModelsCredentials.js?clear-bare-mode' + ) + const prev = process.env.CLAUDE_CODE_SIMPLE process.env.CLAUDE_CODE_SIMPLE = '1' expect(clearGithubModelsToken().success).toBe(true) diff --git a/src/utils/githubModelsCredentials.ts b/src/utils/githubModelsCredentials.ts index 61b07db5..9ddc483d 100644 --- a/src/utils/githubModelsCredentials.ts +++ b/src/utils/githubModelsCredentials.ts @@ -23,6 +23,19 @@ export function readGithubModelsToken(): string | undefined { } } +export async function readGithubModelsTokenAsync(): Promise { + if (isBareMode()) return undefined + try { + const data = (await getSecureStorage().readAsync()) as + | ({ githubModels?: GithubModelsCredentialBlob } & Record) + | null + const t = data?.githubModels?.accessToken?.trim() + return t || undefined + } catch { + return undefined + } +} + /** * If GitHub Models mode is on and no token is in the environment, copy the * stored token into process.env so the OpenAI shim and validation see it.