mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:34:35 +00:00
* fix(test): make resetGateway restore the test baseline instead of unconfiguring (#3554) bunfig.toml's legacy-embedding-preload pins the gateway once at process start to openai:text-embedding-3-large @ 1536, but resetGateway() wiped that pin to _config = null. The next test file's beforeAll engine-connect then reconfigured from the SHIPPED default (zembed-1 @ 1280) before the preload's per-test beforeEach could restore anything, and every 1536-d fixture in that file failed with `expected 1280 dimensions, not 1536`. Which file pairs collided depended on shard bin-packing, so adding ANY test file reshuffled the mines (this is what blocks #3545). Fix: the preload registers its config as a reset baseline via a new test-only seam (__setGatewayResetBaselineForTests); resetGateway() clears all module state as before, then re-applies the baseline. All 93 existing resetGateway() call sites get the correct behavior with zero edits. Production is untouched: nothing in src/ calls resetGateway() or the setter, so the baseline is never registered outside tests and resetGateway() still fully unconfigures there. Five tests genuinely need an unconfigured gateway (no_gateway_config diagnosis, isAvailable=false, the #2590 cold-gateway path, the registry builtin-default tier); they switch to the new __unconfigureGatewayForTests. Two files' hand-rolled "restore the legacy pin in afterAll/finally" workarounds for this exact bug are now redundant and simplified away. Guard test (test/ai/gateway-reset-baseline.test.ts) pins the contract: 1536/openai immediately after resetGateway(), transports still cleared, hard-unconfigure still available. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(test): restore preload's GBRAIN_AUDIT_DIR instead of deleting it (#3554 sibling) Same bug class as the gateway fix in this PR: state set once by a bunfig preload (audit-dir-preload's scratch GBRAIN_AUDIT_DIR), wiped by one file's cleanup, blast radius decided by shard bin-packing. In shard 6, test/minions-shell.test.ts (position 12) unconditionally deleted the var in afterAll; test/audit/audit-dir-preload.test.ts (position 93) then found it undefined and failed 3 tests — and every file in between wrote audit fixtures toward the operator's real ~/.gbrain/audit/. Fix: capture the prior value at file load and conditionally restore it, the same inline save/restore pattern 11 sibling files already use. test/e2e/skill-brain-first.test.ts had the identical unconditional delete in afterEach; fixed the same way. Sweep of every `delete process.env.GBRAIN_AUDIT_DIR` in test/ confirms all remaining sites are conditional restores. Ordered-pair proof (minions-shell.test.ts then audit/audit-dir-preload.test.ts, one process): 3 fail on master, 43/43 pass with this fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
191 lines
7.9 KiB
TypeScript
191 lines
7.9 KiB
TypeScript
/**
|
|
* v0.41.6.0 D1 — embedding credential preflight.
|
|
*
|
|
* Pure-function tests; uses the gateway's configureGateway / resetGateway
|
|
* test seam to drive different recipe / env shapes without touching
|
|
* process.env.
|
|
*/
|
|
import { describe, test, expect, beforeEach, afterAll } from 'bun:test';
|
|
import {
|
|
configureGateway,
|
|
resetGateway,
|
|
__unconfigureGatewayForTests,
|
|
} from '../src/core/ai/gateway.ts';
|
|
import {
|
|
validateEmbeddingCreds,
|
|
formatEmbeddingCredsError,
|
|
EmbeddingCredentialError,
|
|
} from '../src/core/embed-preflight.ts';
|
|
import type { AIGatewayConfig } from '../src/core/ai/types.ts';
|
|
|
|
// This file calls configureGateway() to drive credential-validation
|
|
// scenarios. configureGateway mutates module-level gateway state (_config).
|
|
// beforeEach resets BEFORE each test, but the LAST test leaves its config
|
|
// behind — and bun runs every file in a shard inside ONE process, so that
|
|
// residue (e.g. OPENAI_API_KEY: 'sk-test') bleeds into the next file's
|
|
// isAvailable('embedding') check. That's what made facts-backstop-gating
|
|
// fail intermittently (bin-pack-dependent) on CI shard 10.
|
|
//
|
|
// #3554: resetGateway() now restores the preload's legacy pin itself (the
|
|
// preload registers it via __setGatewayResetBaselineForTests), so a bare
|
|
// reset is safe here — the NEXT file's beforeAll sees the 1536-d baseline,
|
|
// not a null gateway that would seed 1280-d schemas under 1536-d fixtures.
|
|
afterAll(() => {
|
|
resetGateway();
|
|
});
|
|
|
|
function baseConfig(overrides: Partial<AIGatewayConfig> = {}): AIGatewayConfig {
|
|
return {
|
|
embedding_model: 'openai:text-embedding-3-small',
|
|
embedding_dimensions: 1536,
|
|
chat_model: 'anthropic:claude-sonnet-4-6',
|
|
expansion_model: 'anthropic:claude-haiku-4-5',
|
|
env: {},
|
|
base_urls: {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('formatEmbeddingCredsError — user_provided_dims_unset (#1292/D6)', () => {
|
|
test('names the dimension fix, not a model fix', () => {
|
|
const msg = formatEmbeddingCredsError({
|
|
ok: false,
|
|
reason: 'user_provided_dims_unset',
|
|
model: 'litellm:bge-large',
|
|
provider: 'litellm',
|
|
recipeId: 'litellm',
|
|
});
|
|
expect(msg).toMatch(/dimension/i);
|
|
// Points at the ACCEPTED remediation, not the hard-rejected `config set`
|
|
// (config.ts refuses to write embedding_dimensions — a schema-sizing field).
|
|
expect(msg).toMatch(/gbrain init --embedding-dimensions/);
|
|
expect(msg).not.toMatch(/config set embedding_dimensions/);
|
|
});
|
|
});
|
|
|
|
describe('validateEmbeddingCreds', () => {
|
|
beforeEach(() => { resetGateway(); });
|
|
|
|
test('passes when OPENAI_API_KEY is present and openai model is configured', () => {
|
|
configureGateway(baseConfig({ env: { OPENAI_API_KEY: 'sk-test' } }));
|
|
expect(() => validateEmbeddingCreds()).not.toThrow();
|
|
});
|
|
|
|
test('throws EmbeddingCredentialError with reason=missing_env when OPENAI_API_KEY is unset', () => {
|
|
configureGateway(baseConfig({ env: {} }));
|
|
let caught: unknown;
|
|
try { validateEmbeddingCreds(); } catch (e) { caught = e; }
|
|
expect(caught).toBeInstanceOf(EmbeddingCredentialError);
|
|
const e = caught as EmbeddingCredentialError;
|
|
expect(e.diagnosis.ok).toBe(false);
|
|
if (!e.diagnosis.ok) {
|
|
expect(e.diagnosis.reason).toBe('missing_env');
|
|
if (e.diagnosis.reason === 'missing_env') {
|
|
expect(e.diagnosis.missingEnvVars).toEqual(['OPENAI_API_KEY']);
|
|
expect(e.diagnosis.provider).toBe('openai');
|
|
}
|
|
}
|
|
});
|
|
|
|
test('throws missing_env for voyage when VOYAGE_API_KEY is unset', () => {
|
|
configureGateway(baseConfig({ embedding_model: 'voyage:voyage-3-large', env: {} }));
|
|
let caught: unknown;
|
|
try { validateEmbeddingCreds(); } catch (e) { caught = e; }
|
|
expect(caught).toBeInstanceOf(EmbeddingCredentialError);
|
|
const e = caught as EmbeddingCredentialError;
|
|
if (!e.diagnosis.ok && e.diagnosis.reason === 'missing_env') {
|
|
expect(e.diagnosis.missingEnvVars).toEqual(['VOYAGE_API_KEY']);
|
|
expect(e.diagnosis.provider).toBe('voyage');
|
|
} else { expect('expected missing_env').toBe(JSON.stringify(e.diagnosis)); }
|
|
});
|
|
|
|
test('throws missing_env for google when GOOGLE_GENERATIVE_AI_API_KEY is unset', () => {
|
|
configureGateway(baseConfig({ embedding_model: 'google:text-embedding-004', env: {} }));
|
|
let caught: unknown;
|
|
try { validateEmbeddingCreds(); } catch (e) { caught = e; }
|
|
expect(caught).toBeInstanceOf(EmbeddingCredentialError);
|
|
const e = caught as EmbeddingCredentialError;
|
|
if (!e.diagnosis.ok && e.diagnosis.reason === 'missing_env') {
|
|
expect(e.diagnosis.missingEnvVars).toEqual(['GOOGLE_GENERATIVE_AI_API_KEY']);
|
|
} else { expect('expected missing_env').toBe(JSON.stringify(e.diagnosis)); }
|
|
});
|
|
|
|
test('throws no_touchpoint when configured embedding_model points at anthropic', () => {
|
|
configureGateway(baseConfig({
|
|
embedding_model: 'anthropic:claude-3-5-sonnet',
|
|
env: { ANTHROPIC_API_KEY: 'sk-ant-test' },
|
|
}));
|
|
let caught: unknown;
|
|
try { validateEmbeddingCreds(); } catch (e) { caught = e; }
|
|
expect(caught).toBeInstanceOf(EmbeddingCredentialError);
|
|
const e = caught as EmbeddingCredentialError;
|
|
if (!e.diagnosis.ok) {
|
|
expect(e.diagnosis.reason).toBe('no_touchpoint');
|
|
}
|
|
});
|
|
|
|
test('throws unknown_provider when embedding_model uses unknown provider', () => {
|
|
configureGateway(baseConfig({ embedding_model: 'fakeprovider:embed-1', env: {} }));
|
|
let caught: unknown;
|
|
try { validateEmbeddingCreds(); } catch (e) { caught = e; }
|
|
expect(caught).toBeInstanceOf(EmbeddingCredentialError);
|
|
const e = caught as EmbeddingCredentialError;
|
|
if (!e.diagnosis.ok) {
|
|
expect(e.diagnosis.reason).toBe('unknown_provider');
|
|
}
|
|
});
|
|
|
|
test('throws no_gateway_config when gateway was not configured', () => {
|
|
// resetGateway() restores the preload's test baseline (#3554), so this
|
|
// test needs the hard variant to get a genuinely unconfigured gateway.
|
|
__unconfigureGatewayForTests();
|
|
let caught: unknown;
|
|
try { validateEmbeddingCreds(); } catch (e) { caught = e; }
|
|
expect(caught).toBeInstanceOf(EmbeddingCredentialError);
|
|
const e = caught as EmbeddingCredentialError;
|
|
if (!e.diagnosis.ok) {
|
|
expect(e.diagnosis.reason).toBe('no_gateway_config');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('formatEmbeddingCredsError', () => {
|
|
beforeEach(() => { resetGateway(); });
|
|
|
|
test('missing_env produces paste-ready hint naming the env var + --no-embed option', () => {
|
|
configureGateway(baseConfig({ env: {} }));
|
|
let e: EmbeddingCredentialError;
|
|
try { validateEmbeddingCreds(); throw new Error('expected throw'); }
|
|
catch (err) { e = err as EmbeddingCredentialError; }
|
|
expect(e!.userMessage).toContain('OPENAI_API_KEY');
|
|
expect(e!.userMessage).toContain('--no-embed');
|
|
expect(e!.userMessage).toContain('export OPENAI_API_KEY');
|
|
});
|
|
|
|
test('openai-missing message suggests switching to voyage (not openai)', () => {
|
|
configureGateway(baseConfig({ env: {} }));
|
|
let e: EmbeddingCredentialError;
|
|
try { validateEmbeddingCreds(); throw new Error('expected throw'); }
|
|
catch (err) { e = err as EmbeddingCredentialError; }
|
|
// Don't tell user to switch to the provider they already have.
|
|
expect(e!.userMessage).toContain('voyage');
|
|
expect(e!.userMessage).not.toMatch(/Switch providers:.*openai:/);
|
|
});
|
|
|
|
test('voyage-missing message suggests switching to openai', () => {
|
|
configureGateway(baseConfig({ embedding_model: 'voyage:voyage-3-large', env: {} }));
|
|
let e: EmbeddingCredentialError;
|
|
try { validateEmbeddingCreds(); throw new Error('expected throw'); }
|
|
catch (err) { e = err as EmbeddingCredentialError; }
|
|
expect(e!.userMessage).toContain('VOYAGE_API_KEY');
|
|
expect(e!.userMessage).toContain('openai:text-embedding-3-small');
|
|
});
|
|
|
|
test('no_model_configured returns empty-string for ok diagnosis', () => {
|
|
configureGateway(baseConfig({ env: { OPENAI_API_KEY: 'sk-test' } }));
|
|
expect(formatEmbeddingCredsError({
|
|
ok: true, model: 'openai:text-embedding-3-small', provider: 'openai', recipeId: 'openai',
|
|
})).toBe('');
|
|
});
|
|
});
|