diff --git a/src/bin/codex-runtime-router.ts b/src/bin/codex-runtime-router.ts index 009eab38..f4ad92f1 100644 --- a/src/bin/codex-runtime-router.ts +++ b/src/bin/codex-runtime-router.ts @@ -62,8 +62,12 @@ export async function main(argv: string[]): Promise { } } } catch (resolverErr) { - // Resolver module threw unexpectedly — degrade silently to legacy mode const msg = resolverErr instanceof Error ? resolverErr.message : String(resolverErr); + if (resolverErr instanceof Error && resolverErr.name === 'CodexAuthProfileResolutionError') { + process.stderr.write(`[X] codex-auth: ${msg}\n`); + return 1; + } + // Resolver module threw unexpectedly — degrade to legacy mode. process.stderr.write(`[!] codex-auth: profile resolution skipped (${msg})\n`); } } diff --git a/src/codex-auth/resolve-active-profile.ts b/src/codex-auth/resolve-active-profile.ts index 6bb85700..0d7a5667 100644 --- a/src/codex-auth/resolve-active-profile.ts +++ b/src/codex-auth/resolve-active-profile.ts @@ -1,7 +1,7 @@ /** * Synchronous hot-path resolver for the active codex auth profile. <5ms typical. * Precedence: CCS_CODEX_PROFILE env → registry.default → null (legacy ~/.codex). - * Errors degrade gracefully — never throw. + * Legacy fallback is allowed only when no explicit CCS_CODEX_PROFILE was requested. */ import * as fs from 'fs'; import * as path from 'path'; @@ -14,6 +14,13 @@ export interface ResolvedProfile { source: 'env' | 'default'; } +export class CodexAuthProfileResolutionError extends Error { + constructor(message: string) { + super(message); + this.name = 'CodexAuthProfileResolutionError'; + } +} + interface RegistryShape { version?: string; default?: string | null; @@ -23,23 +30,41 @@ interface RegistryShape { /** @param env - Process env map; defaults to process.env. Injectable for tests. */ export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): ResolvedProfile | null { const registryPath = getCodexAuthRegistryPath(); + const envName = (env.CCS_CODEX_PROFILE ?? '').trim(); // F4: silent fallback — no registry means no profiles, legacy mode - if (!fs.existsSync(registryPath)) return null; + if (!fs.existsSync(registryPath)) { + if (envName) { + throw new CodexAuthProfileResolutionError( + `CCS_CODEX_PROFILE='${envName}' is set but ${registryPath} does not exist. Refusing to fall back to ~/.codex.` + ); + } + return null; + } let registry: RegistryShape; try { const raw = fs.readFileSync(registryPath, 'utf8'); const parsed = yaml.load(raw); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - process.stderr.write( - `[!] codex-auth: registry at ${registryPath} is not a valid YAML object, falling back to ~/.codex\n` - ); + const msg = `registry at ${registryPath} is not a valid YAML object`; + if (envName) { + throw new CodexAuthProfileResolutionError( + `CCS_CODEX_PROFILE='${envName}' is set but ${msg}. Refusing to fall back to ~/.codex.` + ); + } + process.stderr.write(`[!] codex-auth: ${msg}, falling back to ~/.codex\n`); return null; } registry = parsed as RegistryShape; } catch (err) { + if (err instanceof CodexAuthProfileResolutionError) throw err; const msg = err instanceof Error ? err.message : String(err); + if (envName) { + throw new CodexAuthProfileResolutionError( + `CCS_CODEX_PROFILE='${envName}' is set but registry YAML is corrupt at ${registryPath} (${msg}). Refusing to fall back to ~/.codex.` + ); + } process.stderr.write( `[!] codex-auth: registry YAML corrupt at ${registryPath} (${msg}), falling back to ~/.codex\n` ); @@ -49,13 +74,11 @@ export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): Reso const profiles = registry.profiles ?? {}; // F2: explicit env override - const envName = (env.CCS_CODEX_PROFILE ?? '').trim(); if (envName) { if (!Object.prototype.hasOwnProperty.call(profiles, envName)) { - process.stderr.write( - `[!] codex-auth: CCS_CODEX_PROFILE='${envName}' not found in registry, falling back to ~/.codex\n` + throw new CodexAuthProfileResolutionError( + `CCS_CODEX_PROFILE='${envName}' not found in registry. Refusing to fall back to ~/.codex.` ); - return null; } return { name: envName, diff --git a/tests/integration/codex-auth/legacy-fallback.test.ts b/tests/integration/codex-auth/legacy-fallback.test.ts index 013cf49c..49626631 100644 --- a/tests/integration/codex-auth/legacy-fallback.test.ts +++ b/tests/integration/codex-auth/legacy-fallback.test.ts @@ -9,7 +9,7 @@ * Cases: * - Empty registry → resolveActiveProfile returns null (legacy mode) * - Missing registry file → returns null (no registry = legacy mode) - * - CCS_CODEX_PROFILE set but registry missing → returns null + stderr warning + * - CCS_CODEX_PROFILE set but registry missing or unmatched → throws to avoid unsafe fallback */ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import * as fs from 'fs'; @@ -74,7 +74,7 @@ describe('legacy fallback — empty registry', () => { }); describe('legacy fallback — CCS_CODEX_PROFILE set but no matching profile', () => { - it('returns null and emits warning when env points to non-existent profile', async () => { + it('throws when env points to non-existent profile', async () => { // Create registry with no profiles const registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml'); fs.mkdirSync(path.dirname(registryPath), { recursive: true }); @@ -82,29 +82,19 @@ describe('legacy fallback — CCS_CODEX_PROFILE set but no matching profile', () mode: 0o600, }); - const stderrLines: string[] = []; - const origWrite = process.stderr.write.bind(process.stderr); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - process.stderr.write = (chunk: any): boolean => { - stderrLines.push(String(chunk)); - return true; - }; + const { resolveActiveProfile } = await import('../../../src/codex-auth/resolve-active-profile'); + expect(() => resolveActiveProfile({ CCS_CODEX_PROFILE: 'ghost-profile' })).toThrow( + /ghost-profile/ + ); + }); - let result; - try { - const { resolveActiveProfile } = await import( - '../../../src/codex-auth/resolve-active-profile' - ); - result = resolveActiveProfile({ CCS_CODEX_PROFILE: 'ghost-profile' }); - } finally { - process.stderr.write = origWrite; - } + it('throws when env is set but registry file is missing', async () => { + const registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml'); + expect(fs.existsSync(registryPath)).toBe(false); - // Should fall back to null (not throw) - expect(result).toBeNull(); - - // Warning emitted to stderr about missing profile - const allStderr = stderrLines.join(''); - expect(allStderr).toContain('ghost-profile'); + const { resolveActiveProfile } = await import('../../../src/codex-auth/resolve-active-profile'); + expect(() => resolveActiveProfile({ CCS_CODEX_PROFILE: 'ghost-profile' })).toThrow( + /does not exist/ + ); }); }); diff --git a/tests/unit/bin/codex-runtime-router.test.ts b/tests/unit/bin/codex-runtime-router.test.ts index 1798b978..93b55316 100644 --- a/tests/unit/bin/codex-runtime-router.test.ts +++ b/tests/unit/bin/codex-runtime-router.test.ts @@ -143,6 +143,37 @@ describe('codex-runtime router — non-auth profile resolution', () => { expect(code).toBe(-1); // CCS branch: entry must not call process.exit() }); + it('fails fast when CCS_CODEX_PROFILE points to a missing registry profile', async () => { + writeRegistry({ + version: '1.0', + default: null, + profiles: {}, + }); + process.env.CCS_CODEX_PROFILE = 'ghost'; + + const stderrMessages: string[] = []; + const origWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk: string | Uint8Array): boolean => { + stderrMessages.push(typeof chunk === 'string' ? chunk : String(chunk)); + return true; + }; + + try { + require.cache[ccsPath] = { exports: {} } as NodeJS.Module; + flushRouterCache(); + require.cache[ccsPath] = { exports: {} } as NodeJS.Module; + + const { main } = require(routerPath) as { main: (argv: string[]) => Promise }; + const code = await main(['node', 'codex-runtime', 'chat']); + + expect(code).toBe(1); + expect(process.env.CODEX_HOME).toBeUndefined(); + expect(stderrMessages.join('')).toContain("CCS_CODEX_PROFILE='ghost'"); + } finally { + process.stderr.write = origWrite; + } + }); + it('preserves an explicit CODEX_HOME already in env — does not overwrite', async () => { const explicitHome = path.join(tempDir, 'explicit-codex-home'); fs.mkdirSync(explicitHome, { recursive: true }); diff --git a/tests/unit/codex-auth/resolve-active-profile.test.ts b/tests/unit/codex-auth/resolve-active-profile.test.ts index 0cb2f95c..1855ad9f 100644 --- a/tests/unit/codex-auth/resolve-active-profile.test.ts +++ b/tests/unit/codex-auth/resolve-active-profile.test.ts @@ -78,6 +78,15 @@ describe('resolveActiveProfile', () => { expect(stderrMessages.some((m) => m.includes('codex-auth'))).toBe(true); }); + it('throws when CCS_CODEX_PROFILE is set and registry YAML is corrupt', () => { + fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + fs.writeFileSync(registryPath, '{ invalid yaml: [[[', { mode: 0o600 }); + + expect(() => resolveActiveProfile({ CCS_CODEX_PROFILE: 'work' })).toThrow( + /Refusing to fall back to ~\/\.codex/ + ); + }); + it('returns source=env when CCS_CODEX_PROFILE matches a registry entry', () => { const profileDir = makeProfileDir('work'); writeRegistry({ @@ -132,28 +141,20 @@ describe('resolveActiveProfile', () => { expect(result?.source).toBe('env'); }); - it('returns null and warns when CCS_CODEX_PROFILE names a profile not in registry', () => { + it('throws when CCS_CODEX_PROFILE names a profile not in registry', () => { writeRegistry({ version: '1.0', default: null, profiles: {}, }); - const stderrMessages: string[] = []; - const origWrite = process.stderr.write.bind(process.stderr); - const spy = spyOn(process.stderr, 'write').mockImplementation( - (msg: string | Uint8Array, ...rest: unknown[]) => { - stderrMessages.push(typeof msg === 'string' ? msg : String(msg)); - return origWrite(msg as string, ...(rest as Parameters).slice(1)); - } + expect(() => resolveActiveProfile({ CCS_CODEX_PROFILE: 'ghost' })).toThrow( + /CCS_CODEX_PROFILE='ghost'/ ); + }); - const result = resolveActiveProfile({ CCS_CODEX_PROFILE: 'ghost' }); - - spy.mockRestore(); - - expect(result).toBeNull(); - expect(stderrMessages.some((m) => m.includes('ghost'))).toBe(true); + it('throws when CCS_CODEX_PROFILE is set but registry file is missing', () => { + expect(() => resolveActiveProfile({ CCS_CODEX_PROFILE: 'ghost' })).toThrow(/does not exist/); }); it('treats empty/whitespace-only CCS_CODEX_PROFILE as unset, falls back to default', () => { diff --git a/ui/src/components/compatible-cli/codex-auth-profiles-card.tsx b/ui/src/components/compatible-cli/codex-auth-profiles-card.tsx index 0b00adc4..c00b02e2 100644 --- a/ui/src/components/compatible-cli/codex-auth-profiles-card.tsx +++ b/ui/src/components/compatible-cli/codex-auth-profiles-card.tsx @@ -10,6 +10,8 @@ */ import { Loader2 } from 'lucide-react'; +import type { TFunction } from 'i18next'; +import { useTranslation } from 'react-i18next'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { @@ -44,20 +46,22 @@ function formatLastUsed(iso: string | null): string { } } -function sourceLabel(source: 'default' | 'env' | 'explicit-codex-home'): string { +function sourceLabel(source: 'default' | 'env' | 'explicit-codex-home', t: TFunction): string { switch (source) { case 'default': - return 'default'; + return t('codex.auth.sourceDefault'); case 'env': - return '$CCS_CODEX_PROFILE'; + return t('codex.auth.sourceEnv'); case 'explicit-codex-home': - return '$CODEX_HOME'; + return t('codex.auth.sourceExplicitCodexHome'); } } // ── Disabled action button with terminal-redirect tooltip ─────────────────── function TerminalOnlyButton({ label }: { label: string }) { + const { t } = useTranslation(); + return ( @@ -69,11 +73,7 @@ function TerminalOnlyButton({ label }: { label: string }) { - - {/* TODO i18n: missing key codex.auth.terminalOnlyTooltip */} - Use ccsx auth switch <name> or{' '} - ccsx auth remove <name> in terminal. - + {t('codex.auth.terminalOnlyTooltip')} ); @@ -90,6 +90,8 @@ function ProfileRow({ isActive: boolean; activeSource?: 'default' | 'env' | 'explicit-codex-home'; }) { + const { t } = useTranslation(); + return ( @@ -97,8 +99,7 @@ function ProfileRow({ {entry.name} {isActive && activeSource && ( - {/* TODO i18n: missing key codex.auth.activeSourceBadge */} - {sourceLabel(activeSource)} + {t('codex.auth.activeSourceBadge', { source: sourceLabel(activeSource, t) })} )} @@ -109,20 +110,18 @@ function ProfileRow({ {entry.authValid ? ( - {/* TODO i18n: missing key codex.auth.statusOk */} - OK + {t('codex.auth.statusOk')} ) : ( - {/* TODO i18n: missing key codex.auth.statusInvalid */} - [!] auth invalid + {t('codex.auth.statusInvalid')} )} - - + + @@ -132,14 +131,14 @@ function ProfileRow({ // ── Main card ──────────────────────────────────────────────────────────────── export function CodexAuthProfilesCard() { + const { t } = useTranslation(); const { data, isLoading, error } = useCodexAuthProfiles(); if (isLoading) { return (
- {/* TODO i18n: missing key codex.auth.loading */} - Loading auth profiles... + {t('codex.auth.loading')}
); } @@ -147,8 +146,7 @@ export function CodexAuthProfilesCard() { if (error || !data) { return (
- {/* TODO i18n: missing key codex.auth.loadError */} - [!] Failed to load codex-auth profiles. + {t('codex.auth.loadError')}
); } @@ -157,13 +155,8 @@ export function CodexAuthProfilesCard() { if (data.profiles.length === 0) { return (
-

- {/* TODO i18n: missing key codex.auth.emptyRegistry */} - [i] No codex-auth profiles. Run{' '} - ccsx auth create <name> to create - one. -

-

Codex will use the default ~/.codex location.

+

{t('codex.auth.emptyRegistry')}

+

{t('codex.auth.legacyCodexHome')}

); } @@ -173,10 +166,7 @@ export function CodexAuthProfilesCard() { return (
- {/* TODO i18n: missing key codex.auth.legacyMode */} - [i] No active profile. Using ~/.codex (legacy). Run{' '} - ccsx auth switch <name> in terminal - to activate one. + {t('codex.auth.legacyMode')}
@@ -188,10 +178,7 @@ export function CodexAuthProfilesCard() { return (
- {/* TODO i18n: missing key codex.auth.externalCodexHome */} - [i] $CODEX_HOME set externally to{' '} - {data.active.codexHome}. Profile registry - not in use for this session. + {t('codex.auth.externalCodexHome', { path: data.active.codexHome })}
@@ -217,16 +204,16 @@ function ActiveBanner({ source: 'default' | 'env' | 'explicit-codex-home'; profiles: CodexAuthProfileEntry[]; }) { + const { t } = useTranslation(); const activeEntry = profiles.find((p) => p.name === name); return (
- {/* TODO i18n: missing key codex.auth.activeProfile */} - Active profile: - {name ?? '(unknown)'} + {t('codex.auth.activeProfile')} + {name ?? t('codex.auth.unknownProfile')} - {sourceLabel(source)} + {sourceLabel(source, t)}
{activeEntry && ( @@ -234,10 +221,12 @@ function ActiveBanner({ {activeEntry.email && {activeEntry.email}} {activeEntry.plan && ( - Plan: {activeEntry.plan} + {t('codex.auth.planLabel')} {activeEntry.plan} )} - {!activeEntry.authValid && [!] auth invalid} + {!activeEntry.authValid && ( + {t('codex.auth.statusInvalid')} + )}
)} @@ -254,18 +243,19 @@ function ProfileTable({ profiles: CodexAuthProfileEntry[]; }; }) { + const { t } = useTranslation(); + return (
- {/* TODO i18n: missing keys codex.auth.col.name/email/plan/lastUsed/status/actions */} - Name - Email - Plan - Last used - Status - Actions + {t('codex.auth.col.name')} + {t('codex.auth.col.email')} + {t('codex.auth.col.plan')} + {t('codex.auth.col.lastUsed')} + {t('codex.auth.col.status')} + {t('codex.auth.col.actions')} diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 578aed34..70ade74b 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -2175,6 +2175,37 @@ const resources = { featureAppsDesc: 'Enable ChatGPT Apps and connectors support.', featureSmartApprovalsLabel: 'Smart approvals', featureSmartApprovalsDesc: 'Route eligible approvals through the guardian flow.', + auth: { + terminalOnlyTooltip: + 'Use ccsx auth switch or ccsx auth remove in terminal.', + activeSourceBadge: '{{source}}', + statusOk: 'OK', + statusInvalid: '[!] auth invalid', + loading: 'Loading auth profiles...', + loadError: '[!] Failed to load codex-auth profiles.', + emptyRegistry: '[i] No codex-auth profiles. Run ccsx auth create to create one.', + legacyCodexHome: 'Codex will use the default ~/.codex location.', + legacyMode: + '[i] No active profile. Using ~/.codex (legacy). Run ccsx auth switch in terminal to activate one.', + externalCodexHome: + '[i] $CODEX_HOME set externally to {{path}}. Profile registry not in use for this session.', + activeProfile: 'Active profile:', + unknownProfile: '(unknown)', + planLabel: 'Plan:', + switchAction: 'Switch', + removeAction: 'Remove', + sourceDefault: 'default', + sourceEnv: '$CCS_CODEX_PROFILE', + sourceExplicitCodexHome: '$CODEX_HOME', + col: { + name: 'Name', + email: 'Email', + plan: 'Plan', + lastUsed: 'Last used', + status: 'Status', + actions: 'Actions', + }, + }, }, droidSettings: { quickControls: 'Quick Controls', @@ -4739,6 +4770,35 @@ const resources = { yes: '是', no: '否', warningsTitle: '警告', + auth: { + terminalOnlyTooltip: '在终端使用 ccsx auth switch 或 ccsx auth remove 。', + activeSourceBadge: '{{source}}', + statusOk: '正常', + statusInvalid: '[!] 认证无效', + loading: '正在加载认证配置...', + loadError: '[!] 加载 codex-auth 配置失败。', + emptyRegistry: '[i] 没有 codex-auth 配置。运行 ccsx auth create 创建一个。', + legacyCodexHome: 'Codex 将使用默认 ~/.codex 位置。', + legacyMode: + '[i] 没有活动配置。正在使用 ~/.codex(旧模式)。在终端运行 ccsx auth switch 激活一个。', + externalCodexHome: '[i] $CODEX_HOME 外部设置为 {{path}}。本会话未使用配置注册表。', + activeProfile: '活动配置:', + unknownProfile: '(未知)', + planLabel: '套餐:', + switchAction: '切换', + removeAction: '移除', + sourceDefault: '默认', + sourceEnv: '$CCS_CODEX_PROFILE', + sourceExplicitCodexHome: '$CODEX_HOME', + col: { + name: '名称', + email: '邮箱', + plan: '套餐', + lastUsed: '上次使用', + status: '状态', + actions: '操作', + }, + }, }, droidSettings: { quickControls: '快捷控制', @@ -7389,6 +7449,37 @@ const resources = { yes: 'Có', no: 'Không', warningsTitle: 'Cảnh báo', + auth: { + terminalOnlyTooltip: + 'Dùng ccsx auth switch hoặc ccsx auth remove trong terminal.', + activeSourceBadge: '{{source}}', + statusOk: 'OK', + statusInvalid: '[!] auth không hợp lệ', + loading: 'Đang tải hồ sơ auth...', + loadError: '[!] Không tải được hồ sơ codex-auth.', + emptyRegistry: '[i] Chưa có hồ sơ codex-auth. Chạy ccsx auth create để tạo.', + legacyCodexHome: 'Codex sẽ dùng vị trí mặc định ~/.codex.', + legacyMode: + '[i] Chưa có hồ sơ active. Đang dùng ~/.codex (legacy). Chạy ccsx auth switch trong terminal để kích hoạt.', + externalCodexHome: + '[i] $CODEX_HOME được đặt bên ngoài là {{path}}. Registry hồ sơ không dùng trong phiên này.', + activeProfile: 'Hồ sơ active:', + unknownProfile: '(không rõ)', + planLabel: 'Gói:', + switchAction: 'Chuyển', + removeAction: 'Xóa', + sourceDefault: 'mặc định', + sourceEnv: '$CCS_CODEX_PROFILE', + sourceExplicitCodexHome: '$CODEX_HOME', + col: { + name: 'Tên', + email: 'Email', + plan: 'Gói', + lastUsed: 'Dùng lần cuối', + status: 'Trạng thái', + actions: 'Thao tác', + }, + }, }, droidSettings: { quickControls: 'Điều khiển nhanh', @@ -9765,6 +9856,38 @@ const resources = { yes: 'はい', no: 'いいえ', warningsTitle: '警告', + auth: { + terminalOnlyTooltip: + 'ターミナルで ccsx auth switch または ccsx auth remove を使用します。', + activeSourceBadge: '{{source}}', + statusOk: 'OK', + statusInvalid: '[!] 認証が無効', + loading: '認証プロファイルを読み込み中...', + loadError: '[!] codex-auth プロファイルの読み込みに失敗しました。', + emptyRegistry: + '[i] codex-auth プロファイルがありません。ccsx auth create を実行して作成します。', + legacyCodexHome: 'Codex はデフォルトの ~/.codex を使用します。', + legacyMode: + '[i] アクティブなプロファイルがありません。~/.codex(レガシー)を使用中です。ターミナルで ccsx auth switch を実行して有効化します。', + externalCodexHome: + '[i] $CODEX_HOME は外部で {{path}} に設定されています。このセッションではプロファイル registry は使われません。', + activeProfile: 'アクティブプロファイル:', + unknownProfile: '(不明)', + planLabel: 'プラン:', + switchAction: '切り替え', + removeAction: '削除', + sourceDefault: 'デフォルト', + sourceEnv: '$CCS_CODEX_PROFILE', + sourceExplicitCodexHome: '$CODEX_HOME', + col: { + name: '名前', + email: 'メール', + plan: 'プラン', + lastUsed: '最終使用', + status: 'ステータス', + actions: '操作', + }, + }, }, codexPage: { title: 'Codex', @@ -12735,6 +12858,38 @@ const resources = { featureAppsDesc: 'ChatGPT 앱 및 커넥터 지원을 활성화합니다.', featureSmartApprovalsLabel: '스마트 승인', featureSmartApprovalsDesc: '가디언 흐름을 통해 적격 승인을 라우팅합니다.', + auth: { + terminalOnlyTooltip: + '터미널에서 ccsx auth switch 또는 ccsx auth remove 을 사용하세요.', + activeSourceBadge: '{{source}}', + statusOk: 'OK', + statusInvalid: '[!] 인증이 유효하지 않음', + loading: '인증 프로필 로드 중...', + loadError: '[!] codex-auth 프로필을 로드하지 못했습니다.', + emptyRegistry: + '[i] codex-auth 프로필이 없습니다. ccsx auth create 을 실행해 생성하세요.', + legacyCodexHome: 'Codex는 기본 ~/.codex 위치를 사용합니다.', + legacyMode: + '[i] 활성 프로필이 없습니다. ~/.codex(레거시)를 사용 중입니다. 터미널에서 ccsx auth switch 을 실행해 활성화하세요.', + externalCodexHome: + '[i] $CODEX_HOME이 외부에서 {{path}}로 설정되었습니다. 이 세션에서는 프로필 registry를 사용하지 않습니다.', + activeProfile: '활성 프로필:', + unknownProfile: '(알 수 없음)', + planLabel: '플랜:', + switchAction: '전환', + removeAction: '제거', + sourceDefault: '기본값', + sourceEnv: '$CCS_CODEX_PROFILE', + sourceExplicitCodexHome: '$CODEX_HOME', + col: { + name: '이름', + email: '이메일', + plan: '플랜', + lastUsed: '마지막 사용', + status: '상태', + actions: '작업', + }, + }, }, droidSettings: { quickControls: '빠른 제어', diff --git a/ui/tests/unit/lib/i18n-codex-auth.test.ts b/ui/tests/unit/lib/i18n-codex-auth.test.ts new file mode 100644 index 00000000..53c48416 --- /dev/null +++ b/ui/tests/unit/lib/i18n-codex-auth.test.ts @@ -0,0 +1,35 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import i18n from '@/lib/i18n'; + +const locales = ['en', 'zh-CN', 'vi', 'ja', 'ko'] as const; + +const codexAuthKeys = [ + ['codex.auth.terminalOnlyTooltip'], + ['codex.auth.loading'], + ['codex.auth.loadError'], + ['codex.auth.emptyRegistry'], + ['codex.auth.externalCodexHome', { path: '/tmp/codex-home' }], + ['codex.auth.activeProfile'], + ['codex.auth.switchAction'], + ['codex.auth.col.name'], + ['codex.auth.col.actions'], +] as const; + +const originalLanguage = i18n.language; + +afterAll(async () => { + await i18n.changeLanguage(originalLanguage); +}); + +describe('codex auth i18n', () => { + it.each(locales)('resolves codex auth dashboard keys for %s', async (locale) => { + await i18n.changeLanguage(locale); + + for (const [key, options] of codexAuthKeys) { + const translated = i18n.t(key, options); + + expect(translated).not.toBe(key); + expect(translated).not.toContain('codex.auth.'); + } + }); +});