import { describe, it, expect, afterEach } from 'vitest'; import { computeDoctorExitCode, runChecks, type CheckResult, type DoctorContext } from './doctor.js'; import { writeFileSync, mkdirSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; describe('doctor checks', () => { const testDir = join(tmpdir(), 'flynn-test-doctor'); afterEach(() => { try { rmSync(testDir, { recursive: true }); } catch {} }); it('computeDoctorExitCode returns 0 with warnings in non-strict mode', () => { const results: CheckResult[] = [ { status: 'pass', label: 'a' }, { status: 'warn', label: 'b' }, ]; expect(computeDoctorExitCode(results, false)).toBe(0); }); it('computeDoctorExitCode returns 1 with warnings in strict mode', () => { const results: CheckResult[] = [ { status: 'pass', label: 'a' }, { status: 'warn', label: 'b' }, ]; expect(computeDoctorExitCode(results, true)).toBe(1); }); it('reports PASS when config file exists and is valid', async () => { mkdirSync(testDir, { recursive: true }); const configPath = join(testDir, 'config.yaml'); writeFileSync(configPath, ` telegram: bot_token: "test-token" allowed_chat_ids: [123] models: default: provider: anthropic model: claude-sonnet `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const configExists = results.find(r => r.label.includes('Config file')); expect(configExists?.status).toBe('pass'); const configParses = results.find(r => r.label.includes('parses')); expect(configParses?.status).toBe('pass'); const configValidates = results.find(r => r.label.includes('validates')); expect(configValidates?.status).toBe('pass'); }); it('reports FAIL when config file does not exist', async () => { const ctx: DoctorContext = { configPath: '/nonexistent/config.yaml', dataDir: testDir }; const results = await runChecks(ctx); const configExists = results.find(r => r.label.includes('Config file')); expect(configExists?.status).toBe('fail'); }); it('reports FAIL on invalid YAML', async () => { mkdirSync(testDir, { recursive: true }); const configPath = join(testDir, 'bad.yaml'); writeFileSync(configPath, '{{{{bad yaml'); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const configParses = results.find(r => r.label.includes('parses')); expect(configParses?.status).toBe('fail'); }); it('reports FAIL on schema validation failure', async () => { mkdirSync(testDir, { recursive: true }); const configPath = join(testDir, 'invalid.yaml'); writeFileSync(configPath, ` telegram: bot_token: "" `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const configValidates = results.find(r => r.label.includes('validates')); expect(configValidates?.status).toBe('fail'); }); it('warns when deprecated server.tailscale_only key is present', async () => { mkdirSync(testDir, { recursive: true }); const configPath = join(testDir, 'config.yaml'); writeFileSync(configPath, ` telegram: bot_token: "test-token" allowed_chat_ids: [123] server: tailscale_only: true models: default: provider: anthropic model: claude-sonnet `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const deprecated = results.find(r => r.label.includes('deprecated keys')); expect(deprecated?.status).toBe('warn'); }); it('reports PASS for writable data directory', async () => { mkdirSync(testDir, { recursive: true }); const configPath = join(testDir, 'config.yaml'); writeFileSync(configPath, ` telegram: bot_token: "test-token" allowed_chat_ids: [123] models: default: provider: anthropic model: claude-sonnet `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const dataDir = results.find(r => r.label.includes('Data directory')); expect(dataDir?.status).toBe('pass'); }); it('reports PASS for accessible session DB', async () => { mkdirSync(testDir, { recursive: true }); const configPath = join(testDir, 'config.yaml'); writeFileSync(configPath, ` telegram: bot_token: "test-token" allowed_chat_ids: [123] models: default: provider: anthropic model: claude-sonnet `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const sessionDb = results.find(r => r.label.includes('Session DB')); expect(sessionDb?.status).toBe('pass'); }); it('reports SKIP for Gmail when not enabled', async () => { mkdirSync(testDir, { recursive: true }); const configPath = join(testDir, 'config.yaml'); writeFileSync(configPath, ` telegram: bot_token: "test-token" allowed_chat_ids: [123] models: default: provider: anthropic model: claude-sonnet `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const gmailCheck = results.find(r => r.label.includes('Gmail')); expect(gmailCheck?.status).toBe('skip'); }); it('reports WARN for Gmail when token missing', async () => { mkdirSync(testDir, { recursive: true }); const configPath = join(testDir, 'config.yaml'); const credsPath = join(testDir, 'gmail-creds.json'); writeFileSync(credsPath, '{}'); writeFileSync(configPath, ` telegram: bot_token: "test-token" allowed_chat_ids: [123] models: default: provider: anthropic model: claude-sonnet automation: gmail: enabled: true credentials_file: "${credsPath}" token_file: "${join(testDir, 'nonexistent-token.json')}" output: channel: telegram peer: "123" `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const gmailCheck = results.find(r => r.label.includes('Gmail')); expect(gmailCheck?.status).toBe('warn'); expect(gmailCheck?.detail).toContain('flynn gmail-auth'); }); it('reports PASS for Gmail when enabled (poll only)', async () => { mkdirSync(testDir, { recursive: true }); const configPath = join(testDir, 'config.yaml'); const credsPath = join(testDir, 'gmail-creds.json'); const tokenPath = join(testDir, 'gmail-token.json'); writeFileSync(credsPath, JSON.stringify({ installed: { project_id: 'test-project' } })); writeFileSync(tokenPath, JSON.stringify({ refresh_token: 'x' })); writeFileSync(configPath, ` telegram: bot_token: "test-token" allowed_chat_ids: [123] models: default: provider: anthropic model: claude-sonnet automation: gmail: enabled: true credentials_file: "${credsPath}" token_file: "${tokenPath}" output: channel: telegram peer: "123" `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const gmailCheck = results.find(r => r.label.includes('Gmail configured')); expect(gmailCheck?.status).toBe('pass'); expect(gmailCheck?.detail).toContain('poll'); }); it('reports WARN for Gmail when pubsub_topic shorthand used without project_id', async () => { mkdirSync(testDir, { recursive: true }); const configPath = join(testDir, 'config.yaml'); const credsPath = join(testDir, 'gmail-creds.json'); const tokenPath = join(testDir, 'gmail-token.json'); writeFileSync(credsPath, '{}'); writeFileSync(tokenPath, JSON.stringify({ refresh_token: 'x' })); writeFileSync(configPath, ` telegram: bot_token: "test-token" allowed_chat_ids: [123] models: default: provider: anthropic model: claude-sonnet automation: gmail: enabled: true credentials_file: "${credsPath}" token_file: "${tokenPath}" pubsub_topic: gmail-push output: channel: telegram peer: "123" `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const gmailCheck = results.find(r => r.label.includes('Gmail configured')); expect(gmailCheck?.status).toBe('warn'); expect(gmailCheck?.detail).toContain('pubsub_topic shorthand'); }); it('skips downstream checks when config is invalid', async () => { const ctx: DoctorContext = { configPath: '/nonexistent/config.yaml', dataDir: testDir }; const results = await runChecks(ctx); const modelCheck = results.find(r => r.label.includes('Model connectivity')); expect(modelCheck?.status).toBe('skip'); const telegramCheck = results.find(r => r.label.includes('Telegram')); expect(telegramCheck?.status).toBe('skip'); }); it('reports WARN for skills when configured directories are missing', async () => { mkdirSync(testDir, { recursive: true }); const configPath = join(testDir, 'skills-missing.yaml'); writeFileSync(configPath, ` telegram: bot_token: "test-token" allowed_chat_ids: [123] models: default: provider: anthropic model: claude-sonnet skills: bundled_dir: "${join(testDir, 'missing-bundled')}" `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const skillsCheck = results.find(r => r.label.includes('Skills loaded')); expect(skillsCheck?.status).toBe('warn'); expect(skillsCheck?.detail).toContain('missing dirs'); }); it('reports PASS for skills when configured directory exists', async () => { const bundledDir = join(testDir, 'bundled'); const sampleSkillDir = join(bundledDir, 'sample'); mkdirSync(sampleSkillDir, { recursive: true }); writeFileSync(join(sampleSkillDir, 'SKILL.md'), '# Sample skill'); const configPath = join(testDir, 'skills-pass.yaml'); writeFileSync(configPath, ` telegram: bot_token: "test-token" allowed_chat_ids: [123] models: default: provider: anthropic model: claude-sonnet skills: bundled_dir: "${bundledDir}" `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const skillsCheck = results.find(r => r.label.includes('Skills loaded')); expect(skillsCheck?.status).toBe('pass'); expect(skillsCheck?.detail).toContain('1 skill(s), 1 available, 0 unavailable'); }); it('reports WARN for skills registry when unconfigured', async () => { mkdirSync(testDir, { recursive: true }); const configPath = join(testDir, 'registry-unconfigured.yaml'); writeFileSync(configPath, ` telegram: bot_token: "test-token" allowed_chat_ids: [123] models: default: provider: anthropic model: claude-sonnet `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const registryCheck = results.find(r => r.label === 'Skills registry'); expect(registryCheck?.status).toBe('warn'); expect(registryCheck?.detail).toContain('unconfigured'); }); it('reports SKIP for MinIO ingest extractors when MinIO is disabled', async () => { mkdirSync(testDir, { recursive: true }); const configPath = join(testDir, 'minio-disabled.yaml'); writeFileSync(configPath, ` telegram: bot_token: "test-token" allowed_chat_ids: [123] models: default: provider: anthropic model: claude-sonnet backup: minio: enabled: false `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const minioCheck = results.find(r => r.label.includes('MinIO ingest extractors')); expect(minioCheck?.status).toBe('skip'); }); it('reports MinIO ingest extractor status when MinIO is enabled', async () => { mkdirSync(testDir, { recursive: true }); const configPath = join(testDir, 'minio-enabled.yaml'); writeFileSync(configPath, ` telegram: bot_token: "test-token" allowed_chat_ids: [123] models: default: provider: anthropic model: claude-sonnet backup: minio: enabled: true `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const minioCheck = results.find(r => r.label.includes('MinIO ingest extractors')); expect(minioCheck).toBeDefined(); expect(['pass', 'warn']).toContain(minioCheck?.status); expect(minioCheck?.detail).toContain('pdf:'); expect(minioCheck?.detail).toContain('docx:'); }); it('reports PASS for skills registry when source is parsable', async () => { mkdirSync(testDir, { recursive: true }); const registryPath = join(testDir, 'registry.json'); writeFileSync(registryPath, JSON.stringify({ skills: [] }), 'utf-8'); const configPath = join(testDir, 'registry-pass.yaml'); writeFileSync(configPath, ` telegram: bot_token: "test-token" allowed_chat_ids: [123] models: default: provider: anthropic model: claude-sonnet skills: registry_source: "${registryPath}" `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const registryCheck = results.find(r => r.label === 'Skills registry'); expect(registryCheck?.status).toBe('pass'); expect(registryCheck?.detail).toContain('loaded 0 entries'); }); it('reports FAIL for skills registry when source cannot be loaded', async () => { mkdirSync(testDir, { recursive: true }); const configPath = join(testDir, 'registry-fail.yaml'); writeFileSync(configPath, ` telegram: bot_token: "test-token" allowed_chat_ids: [123] models: default: provider: anthropic model: claude-sonnet skills: registry_source: "${join(testDir, 'missing-registry.json')}" `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const registryCheck = results.find(r => r.label === 'Skills registry'); expect(registryCheck?.status).toBe('fail'); }); it('reports FAIL when OpenAI auth_mode=api_key has no available key sources', async () => { const originalHome = process.env.HOME; const originalKey = process.env.OPENAI_API_KEY; delete process.env.OPENAI_API_KEY; const homeDir = join(tmpdir(), `flynn-test-doctor-home-${Date.now()}`); process.env.HOME = homeDir; try { mkdirSync(testDir, { recursive: true }); const configPath = join(testDir, 'openai-missing.yaml'); writeFileSync(configPath, ` telegram: bot_token: "test-token" allowed_chat_ids: [123] models: default: provider: openai model: gpt-4o auth_mode: api_key `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const modelCheck = results.find(r => r.label.includes('Model connectivity')) as CheckResult | undefined; expect(modelCheck?.status).toBe('fail'); expect(modelCheck?.detail).toContain('OPENAI_API_KEY'); expect(modelCheck?.detail).toContain('flynn openai-key'); } finally { process.env.HOME = originalHome; if (originalKey) { process.env.OPENAI_API_KEY = originalKey; } else { delete process.env.OPENAI_API_KEY; } } }); it('loads env vars from FLYNN_ENV_FILE before env-var checks', async () => { const originalEnvFile = process.env.FLYNN_ENV_FILE; const originalOpenAIKey = process.env.OPENAI_API_KEY; delete process.env.OPENAI_API_KEY; try { mkdirSync(testDir, { recursive: true }); const envPath = join(testDir, 'cloud.env'); writeFileSync(envPath, 'OPENAI_API_KEY=sk-test-from-env-file\n'); process.env.FLYNN_ENV_FILE = envPath; const configPath = join(testDir, 'openai-env-file.yaml'); writeFileSync(configPath, ` telegram: bot_token: "test-token" allowed_chat_ids: [123] models: default: provider: openai model: gpt-5.2 auth_mode: api_key api_key: \${OPENAI_API_KEY} `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const envCheck = results.find((r) => r.label.includes('Env vars resolved')) as CheckResult | undefined; const modelCheck = results.find((r) => r.label.includes('Model connectivity')) as CheckResult | undefined; expect(envCheck?.status).toBe('pass'); expect(modelCheck?.status).toBe('pass'); expect(modelCheck?.detail).toContain('api_key=config+env'); } finally { if (originalEnvFile !== undefined) { process.env.FLYNN_ENV_FILE = originalEnvFile; } else { delete process.env.FLYNN_ENV_FILE; } if (originalOpenAIKey !== undefined) { process.env.OPENAI_API_KEY = originalOpenAIKey; } else { delete process.env.OPENAI_API_KEY; } } }); it('reports WARN when Vercel AI Gateway has no available API key sources', async () => { const originalKey = process.env.AI_GATEWAY_API_KEY; delete process.env.AI_GATEWAY_API_KEY; try { mkdirSync(testDir, { recursive: true }); const configPath = join(testDir, 'vercel-missing.yaml'); writeFileSync(configPath, ` telegram: bot_token: "test-token" allowed_chat_ids: [123] models: default: provider: vercel model: openai/gpt-4.1 `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const modelCheck = results.find(r => r.label.includes('Model connectivity')) as CheckResult | undefined; expect(modelCheck?.status).toBe('warn'); expect(modelCheck?.detail).toContain('AI_GATEWAY_API_KEY'); } finally { if (originalKey) { process.env.AI_GATEWAY_API_KEY = originalKey; } else { delete process.env.AI_GATEWAY_API_KEY; } } }); it('reports WARN when MiniMax has no available API key sources', async () => { const originalKey = process.env.MINIMAX_API_KEY; delete process.env.MINIMAX_API_KEY; try { mkdirSync(testDir, { recursive: true }); const configPath = join(testDir, 'minimax-missing.yaml'); writeFileSync(configPath, ` telegram: bot_token: "test-token" allowed_chat_ids: [123] models: default: provider: minimax model: MiniMax-M1 `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const modelCheck = results.find(r => r.label.includes('Model connectivity')) as CheckResult | undefined; expect(modelCheck?.status).toBe('warn'); expect(modelCheck?.detail).toContain('MINIMAX_API_KEY'); } finally { if (originalKey) { process.env.MINIMAX_API_KEY = originalKey; } else { delete process.env.MINIMAX_API_KEY; } } }); it('reports WARN when Moonshot has no available API key sources', async () => { const originalKey = process.env.MOONSHOT_API_KEY; delete process.env.MOONSHOT_API_KEY; try { mkdirSync(testDir, { recursive: true }); const configPath = join(testDir, 'moonshot-missing.yaml'); writeFileSync(configPath, ` telegram: bot_token: "test-token" allowed_chat_ids: [123] models: default: provider: moonshot model: moonshot-v1-8k `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const modelCheck = results.find(r => r.label.includes('Model connectivity')) as CheckResult | undefined; expect(modelCheck?.status).toBe('warn'); expect(modelCheck?.detail).toContain('MOONSHOT_API_KEY'); } finally { if (originalKey) { process.env.MOONSHOT_API_KEY = originalKey; } else { delete process.env.MOONSHOT_API_KEY; } } }); it('reports FAIL when Anthropic auth_mode=oauth has no available auth token sources', async () => { const originalHome = process.env.HOME; const originalToken = process.env.ANTHROPIC_AUTH_TOKEN; delete process.env.ANTHROPIC_AUTH_TOKEN; const homeDir = join(tmpdir(), `flynn-test-doctor-home-${Date.now()}-2`); process.env.HOME = homeDir; try { mkdirSync(testDir, { recursive: true }); const configPath = join(testDir, 'anthropic-missing-token.yaml'); writeFileSync(configPath, ` telegram: bot_token: "test-token" allowed_chat_ids: [123] models: default: provider: anthropic model: claude-sonnet auth_mode: oauth `); const ctx: DoctorContext = { configPath, dataDir: testDir }; const results = await runChecks(ctx); const modelCheck = results.find(r => r.label.includes('Model connectivity')) as CheckResult | undefined; expect(modelCheck?.status).toBe('fail'); expect(modelCheck?.detail).toContain('ANTHROPIC_AUTH_TOKEN'); expect(modelCheck?.detail).toContain('flynn anthropic-auth --token'); } finally { process.env.HOME = originalHome; if (originalToken) { process.env.ANTHROPIC_AUTH_TOKEN = originalToken; } else { delete process.env.ANTHROPIC_AUTH_TOKEN; } } }); });