Files
flynn/src/cli/doctor.test.ts
T

664 lines
21 KiB
TypeScript

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;
}
}
});
});