mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
Core of the v0.37.10.0 wave (D1-D7, D9-D11). Closes the bug where a fresh \`gbrain init --pglite\` silently produced a broken brain when no provider key matched the v0.36 default. resolveAIOptions rewritten with per-touchpoint env detection: - Explicit flag → shorthand → env auto-pick (group by provider id, codex #2) - Picker fires when multiple providers env-ready (D1+D2 hybrid) - Non-TTY zero-key exits 1 with paste-ready setup hint (D3) + Levenshtein typo detection for OPENAPI_API_KEY → OPENAI_API_KEY (D13) - All three touchpoints covered (embedding + expansion + chat, D4) - Local-only providers (Ollama/llama-server) excluded from auto-pick; picking Ollama silently when user has OPENAI_API_KEY set was wrong UX initPGLite + initPostgres: - Drop conditional configureGateway gate → always call before initSchema - Preflight resolveSchemaEmbeddingDim() BEFORE engine.initSchema() (D11) — invalid dim refuses with paste-ready hint, no disk write - Atomic embedding-config persistence (codex #13): either resolved tuple or embedding_disabled:true sentinel, never partial state - Post-initSchema invariant assertion stays as regression guardrail - --no-embedding opt-in flag (D9) for deferred-setup mode - Subagent-Anthropic caveat (D7) fires post-init when chat_model is non-Anthropic AND ANTHROPIC_API_KEY missing Exported groupReadyByProvider() + findEnvKeyTypos() for unit testing. Tests: 21 unit cases covering provider grouping + typo detection edge cases.
152 lines
6.2 KiB
TypeScript
152 lines
6.2 KiB
TypeScript
/**
|
|
* T5 — env-detection helpers in resolveAIOptions.
|
|
*
|
|
* These tests exercise the exported pure helpers (groupReadyByProvider,
|
|
* findEnvKeyTypos) with hermetic env injections. The resolveAIOptions
|
|
* orchestration itself is exercised end-to-end via T12's
|
|
* test/e2e/init-fresh-pglite.test.ts (PTY-based, real CLI).
|
|
*
|
|
* Per CLAUDE.md test isolation rules: env mutations would normally need
|
|
* `withEnv`, but these helpers accept env as an argument — purer DI, no
|
|
* process.env touched, no quarantine needed.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { groupReadyByProvider, findEnvKeyTypos } from '../src/commands/init.ts';
|
|
|
|
describe('groupReadyByProvider — embedding touchpoint', () => {
|
|
test('OPENAI_API_KEY alone → openai is ready', async () => {
|
|
const got = await groupReadyByProvider('embedding', { OPENAI_API_KEY: 'sk-test' });
|
|
expect(got.map(p => p.recipeId)).toContain('openai');
|
|
});
|
|
|
|
test('VOYAGE_API_KEY alone → voyage is ready', async () => {
|
|
const got = await groupReadyByProvider('embedding', { VOYAGE_API_KEY: 'pa-test' });
|
|
expect(got.map(p => p.recipeId)).toContain('voyage');
|
|
});
|
|
|
|
test('ZEROENTROPY_API_KEY alone → zeroentropyai is ready', async () => {
|
|
const got = await groupReadyByProvider('embedding', { ZEROENTROPY_API_KEY: 'ze-test' });
|
|
expect(got.map(p => p.recipeId)).toContain('zeroentropyai');
|
|
});
|
|
|
|
test('OPENAI_API_KEY + VOYAGE_API_KEY → both providers in ready list', async () => {
|
|
const got = await groupReadyByProvider('embedding', {
|
|
OPENAI_API_KEY: 'sk-test',
|
|
VOYAGE_API_KEY: 'pa-test',
|
|
});
|
|
const ids = got.map(p => p.recipeId);
|
|
expect(ids).toContain('openai');
|
|
expect(ids).toContain('voyage');
|
|
});
|
|
|
|
test('each provider appears at most once (codex finding #2 dedup)', async () => {
|
|
const got = await groupReadyByProvider('embedding', {
|
|
OPENAI_API_KEY: 'sk-test',
|
|
VOYAGE_API_KEY: 'pa-test',
|
|
ZEROENTROPY_API_KEY: 'ze-test',
|
|
});
|
|
const ids = got.map(p => p.recipeId);
|
|
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
|
|
expect(dupes).toEqual([]);
|
|
});
|
|
|
|
test('empty-string env var counts as not set', async () => {
|
|
const got = await groupReadyByProvider('embedding', { OPENAI_API_KEY: '' });
|
|
expect(got.map(p => p.recipeId)).not.toContain('openai');
|
|
});
|
|
|
|
test('Anthropic alone → not in embedding ready (no embedding touchpoint on anthropic recipe)', async () => {
|
|
const got = await groupReadyByProvider('embedding', { ANTHROPIC_API_KEY: 'sk-ant-test' });
|
|
expect(got.map(p => p.recipeId)).not.toContain('anthropic');
|
|
});
|
|
|
|
test('regression: bug reporter scenario — only OPENAI_API_KEY set → openai picked, ZE not present', async () => {
|
|
const got = await groupReadyByProvider('embedding', { OPENAI_API_KEY: 'sk-test' });
|
|
const ids = got.map(p => p.recipeId);
|
|
expect(ids).toContain('openai');
|
|
expect(ids).not.toContain('zeroentropyai');
|
|
});
|
|
});
|
|
|
|
describe('groupReadyByProvider — chat touchpoint', () => {
|
|
test('OPENAI_API_KEY → openai chat ready', async () => {
|
|
const got = await groupReadyByProvider('chat', { OPENAI_API_KEY: 'sk-test' });
|
|
expect(got.map(p => p.recipeId)).toContain('openai');
|
|
});
|
|
|
|
test('ZEROENTROPY_API_KEY alone → no chat ready (ZE has no chat touchpoint)', async () => {
|
|
const got = await groupReadyByProvider('chat', { ZEROENTROPY_API_KEY: 'ze-test' });
|
|
expect(got.map(p => p.recipeId)).not.toContain('zeroentropyai');
|
|
});
|
|
|
|
test('ANTHROPIC_API_KEY → anthropic chat ready', async () => {
|
|
const got = await groupReadyByProvider('chat', { ANTHROPIC_API_KEY: 'sk-ant-test' });
|
|
expect(got.map(p => p.recipeId)).toContain('anthropic');
|
|
});
|
|
});
|
|
|
|
describe('groupReadyByProvider — expansion touchpoint', () => {
|
|
test('OPENAI_API_KEY → openai expansion ready', async () => {
|
|
const got = await groupReadyByProvider('expansion', { OPENAI_API_KEY: 'sk-test' });
|
|
expect(got.map(p => p.recipeId)).toContain('openai');
|
|
});
|
|
|
|
test('VOYAGE_API_KEY alone → no expansion ready (Voyage is embedding-only)', async () => {
|
|
const got = await groupReadyByProvider('expansion', { VOYAGE_API_KEY: 'pa-test' });
|
|
expect(got.map(p => p.recipeId)).not.toContain('voyage');
|
|
});
|
|
});
|
|
|
|
describe('findEnvKeyTypos', () => {
|
|
test('detects OPENAPI_API_KEY → OPENAI_API_KEY', async () => {
|
|
const got = await findEnvKeyTypos({ OPENAPI_API_KEY: 'sk-test' });
|
|
expect(got.length).toBeGreaterThan(0);
|
|
const openaiTypo = got.find(t => t.userSet === 'OPENAPI_API_KEY');
|
|
expect(openaiTypo).toBeDefined();
|
|
expect(openaiTypo!.suggested).toBe('OPENAI_API_KEY');
|
|
});
|
|
|
|
test('no typo when canonical name is also set (false-positive guard)', async () => {
|
|
const got = await findEnvKeyTypos({
|
|
OPENAPI_API_KEY: 'sk-test',
|
|
OPENAI_API_KEY: 'sk-real',
|
|
});
|
|
// OPENAPI_API_KEY → OPENAI_API_KEY suggestion suppressed
|
|
expect(got.find(t => t.userSet === 'OPENAPI_API_KEY')).toBeUndefined();
|
|
});
|
|
|
|
test('empty env returns no typos', async () => {
|
|
const got = await findEnvKeyTypos({});
|
|
expect(got).toEqual([]);
|
|
});
|
|
|
|
test('canonical name set produces no typo for itself', async () => {
|
|
const got = await findEnvKeyTypos({ OPENAI_API_KEY: 'sk-test' });
|
|
expect(got.find(t => t.userSet === 'OPENAI_API_KEY')).toBeUndefined();
|
|
});
|
|
|
|
test('non-API-KEY shaped vars ignored (HOME, PATH, etc.)', async () => {
|
|
const got = await findEnvKeyTypos({ HOME: '/home/user', PATH: '/usr/bin' });
|
|
expect(got).toEqual([]);
|
|
});
|
|
|
|
test('empty-string env var skipped (no suggestion)', async () => {
|
|
const got = await findEnvKeyTypos({ OPENAPI_API_KEY: '' });
|
|
expect(got.find(t => t.userSet === 'OPENAPI_API_KEY')).toBeUndefined();
|
|
});
|
|
|
|
test('detects VOYAG_API_KEY → VOYAGE_API_KEY (1 char delete)', async () => {
|
|
const got = await findEnvKeyTypos({ VOYAG_API_KEY: 'pa-test' });
|
|
const v = got.find(t => t.userSet === 'VOYAG_API_KEY');
|
|
expect(v).toBeDefined();
|
|
expect(v!.suggested).toBe('VOYAGE_API_KEY');
|
|
});
|
|
|
|
test('very-different name returns no typo (far beyond edit distance)', async () => {
|
|
const got = await findEnvKeyTypos({ COMPLETELY_UNRELATED_KEY: 'foo' });
|
|
// Should not match any canonical via Levenshtein ≤ 3.
|
|
expect(got.find(t => t.userSet === 'COMPLETELY_UNRELATED_KEY')).toBeUndefined();
|
|
});
|
|
});
|