mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-29 19:01:39 +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>
326 lines
15 KiB
TypeScript
326 lines
15 KiB
TypeScript
/**
|
|
* v0.37 fix wave — fresh-install PGLite embedding setup.
|
|
*
|
|
* Covers the multi-bug-class fix surfaced by the user's 9-bug report and
|
|
* the two codex outside-voice review rounds (26 findings folded). Each
|
|
* test pins a specific finding so future regressions surface fast.
|
|
*
|
|
* Test framework: bun:test. Hermetic — no network, no DATABASE_URL needed.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'fs';
|
|
import { tmpdir } from 'os';
|
|
import { join } from 'path';
|
|
|
|
// Lane A — defaults sweep
|
|
describe('v0.37 Lane A — defaults sweep', () => {
|
|
test('A.0: gateway re-exports DEFAULT_EMBEDDING_MODEL + DEFAULT_EMBEDDING_DIMENSIONS', async () => {
|
|
// CDX2-1: these were file-private const; Lane A consumers (schema
|
|
// helpers, registry) need them exported. Importing here is the test.
|
|
const { DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS } = await import('../src/core/ai/gateway.ts');
|
|
expect(DEFAULT_EMBEDDING_MODEL).toBe('zeroentropyai:zembed-1');
|
|
expect(DEFAULT_EMBEDDING_DIMENSIONS).toBe(1280);
|
|
});
|
|
|
|
test('A.0: ai/defaults.ts is the canonical source (leaf module, no SDK pulls)', async () => {
|
|
const defaults = await import('../src/core/ai/defaults.ts');
|
|
expect(defaults.DEFAULT_EMBEDDING_MODEL).toBe('zeroentropyai:zembed-1');
|
|
expect(defaults.DEFAULT_EMBEDDING_DIMENSIONS).toBe(1280);
|
|
});
|
|
|
|
// T-11 / T-12: registry + schema defaults track gateway constants.
|
|
test('A.1: getPGLiteSchema() default-args produce a vector(1280) column', async () => {
|
|
const { getPGLiteSchema } = await import('../src/core/pglite-schema.ts');
|
|
const sql = getPGLiteSchema(); // no args — uses defaults
|
|
expect(sql).toContain('vector(1280)');
|
|
expect(sql).not.toContain('vector(1536)');
|
|
});
|
|
|
|
test('A.2: getPostgresSchema() default-args produce a vector(1280) column', async () => {
|
|
const { getPostgresSchema } = await import('../src/core/postgres-engine.ts');
|
|
const sql = getPostgresSchema();
|
|
expect(sql).toContain('vector(1280)');
|
|
expect(sql).not.toContain('vector(1536)');
|
|
});
|
|
|
|
test('A.2: getPostgresSchema() with explicit args still routes the override', async () => {
|
|
const { getPostgresSchema } = await import('../src/core/postgres-engine.ts');
|
|
const sql = getPostgresSchema(2048, 'voyage:voyage-4-large');
|
|
expect(sql).toContain('vector(2048)');
|
|
expect(sql).not.toContain('vector(1280)');
|
|
expect(sql).toContain('voyage:voyage-4-large');
|
|
});
|
|
|
|
test('A.5: embedding-column registry builtin defaults to ZE/1280 on empty config + gateway', async () => {
|
|
// The registry's resolution chain is cfg > gateway > DEFAULT. With
|
|
// no cfg AND no gateway, it should fall through to the canonical
|
|
// default (ZE/1280). Hard-unconfigure first to exercise that path —
|
|
// resetGateway() would restore the preload's 1536 baseline (#3554).
|
|
const { __unconfigureGatewayForTests, resetGateway } = await import('../src/core/ai/gateway.ts');
|
|
const { getEmbeddingColumnRegistry } = await import('../src/core/search/embedding-column.ts');
|
|
__unconfigureGatewayForTests();
|
|
try {
|
|
const reg = getEmbeddingColumnRegistry({ engine: 'pglite' } as any);
|
|
expect(reg['embedding']).toBeDefined();
|
|
expect(reg['embedding'].provider).toBe('zeroentropyai:zembed-1');
|
|
expect(reg['embedding'].dimensions).toBe(1280);
|
|
} finally {
|
|
// Restore the preload's legacy baseline so the rest of the file's
|
|
// tests (and subsequent files in this shard) see a configured gateway.
|
|
resetGateway();
|
|
}
|
|
});
|
|
|
|
test('A.5: registry tracks gateway when cfg is empty (gateway as fallback)', async () => {
|
|
// The new "gateway tier" of the resolution chain. Tests configure
|
|
// the gateway to OpenAI/1536 (via preload); registry reflects that
|
|
// even with empty cfg. Lets test fixtures avoid duplicating the
|
|
// model config in two places.
|
|
const { getEmbeddingColumnRegistry } = await import('../src/core/search/embedding-column.ts');
|
|
const reg = getEmbeddingColumnRegistry({ engine: 'pglite' } as any);
|
|
expect(reg['embedding']).toBeDefined();
|
|
expect(reg['embedding'].provider).toBe('openai:text-embedding-3-large');
|
|
expect(reg['embedding'].dimensions).toBe(1536);
|
|
});
|
|
|
|
test('A.6: isCacheSafe baselines against gateway state (not stale constants)', async () => {
|
|
// With the preload setting gateway to OpenAI/1536, isCacheSafe
|
|
// considers a 1536/OpenAI resolved column safe even when cfg has
|
|
// no embedding_model.
|
|
const { isCacheSafe } = await import('../src/core/search/embedding-column.ts');
|
|
const resolved1536 = {
|
|
name: 'embedding',
|
|
dimensions: 1536,
|
|
embeddingModel: 'openai:text-embedding-3-large',
|
|
type: 'vector' as const,
|
|
provider: 'openai:text-embedding-3-large',
|
|
};
|
|
expect(isCacheSafe(resolved1536 as any, { engine: 'pglite' } as any)).toBe(true);
|
|
|
|
// Wrong dim → unsafe.
|
|
const wrongDim = { ...resolved1536, dimensions: 1280 };
|
|
expect(isCacheSafe(wrongDim as any, { engine: 'pglite' } as any)).toBe(false);
|
|
|
|
// Wrong model → unsafe.
|
|
const wrongModel = { ...resolved1536, embeddingModel: 'voyage:voyage-3-large' };
|
|
expect(isCacheSafe(wrongModel as any, { engine: 'pglite' } as any)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// Lane B — init paths + B.4 file-plane merge
|
|
describe('v0.37 Lane B — init paths', () => {
|
|
let tmpHome: string;
|
|
let origHome: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-v37-test-'));
|
|
origHome = process.env.GBRAIN_HOME;
|
|
process.env.GBRAIN_HOME = tmpHome;
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (origHome === undefined) delete process.env.GBRAIN_HOME;
|
|
else process.env.GBRAIN_HOME = origHome;
|
|
});
|
|
|
|
test('B.4 / T-3: loadConfigFileOnly ignores env overrides', async () => {
|
|
const cfgPath = join(tmpHome, '.gbrain', 'config.json');
|
|
require('fs').mkdirSync(join(tmpHome, '.gbrain'), { recursive: true });
|
|
writeFileSync(cfgPath, JSON.stringify({
|
|
engine: 'pglite',
|
|
database_path: '/file/plane/path',
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 1536,
|
|
}));
|
|
|
|
process.env.GBRAIN_EMBEDDING_MODEL = 'voyage:voyage-3-large';
|
|
process.env.GBRAIN_EMBEDDING_DIMENSIONS = '2048';
|
|
process.env.OPENAI_API_KEY = 'sk-from-env';
|
|
process.env.OPENROUTER_API_KEY = 'sk-or-from-env';
|
|
|
|
// Force re-import to pick up env state (the module-level resolver in
|
|
// config.ts reads process.env at call time, so this is safe).
|
|
delete require.cache[require.resolve('../src/core/config.ts')];
|
|
const { loadConfigFileOnly, loadConfig } = await import('../src/core/config.ts');
|
|
|
|
const fileOnly = loadConfigFileOnly();
|
|
expect(fileOnly?.embedding_model).toBe('openai:text-embedding-3-large');
|
|
expect(fileOnly?.embedding_dimensions).toBe(1536);
|
|
// CDX-5 regression: env keys must NOT leak into file-only loader.
|
|
expect(fileOnly?.openai_api_key).toBeUndefined();
|
|
expect(fileOnly?.openrouter_api_key).toBeUndefined();
|
|
|
|
// Control: loadConfig() DOES merge env.
|
|
const merged = loadConfig();
|
|
expect(merged?.embedding_model).toBe('voyage:voyage-3-large');
|
|
expect(merged?.embedding_dimensions).toBe(2048);
|
|
expect(merged?.openai_api_key).toBe('sk-from-env');
|
|
expect(merged?.openrouter_api_key).toBe('sk-or-from-env');
|
|
|
|
delete process.env.GBRAIN_EMBEDDING_MODEL;
|
|
delete process.env.GBRAIN_EMBEDDING_DIMENSIONS;
|
|
delete process.env.OPENAI_API_KEY;
|
|
delete process.env.OPENROUTER_API_KEY;
|
|
});
|
|
|
|
test('B.4 / CDX-5: loadConfigFileOnly does NOT infer engine from DATABASE_URL', async () => {
|
|
const cfgPath = join(tmpHome, '.gbrain', 'config.json');
|
|
require('fs').mkdirSync(join(tmpHome, '.gbrain'), { recursive: true });
|
|
writeFileSync(cfgPath, JSON.stringify({
|
|
engine: 'pglite',
|
|
database_path: '/pglite/path',
|
|
}));
|
|
|
|
process.env.DATABASE_URL = 'postgres://transient@host/db';
|
|
delete require.cache[require.resolve('../src/core/config.ts')];
|
|
const { loadConfigFileOnly, loadConfig } = await import('../src/core/config.ts');
|
|
|
|
const fileOnly = loadConfigFileOnly();
|
|
expect(fileOnly?.engine).toBe('pglite');
|
|
expect(fileOnly?.database_path).toBe('/pglite/path');
|
|
expect(fileOnly?.database_url).toBeUndefined();
|
|
|
|
// Control: loadConfig() WOULD infer postgres from the env URL.
|
|
const merged = loadConfig();
|
|
expect(merged?.engine).toBe('postgres');
|
|
|
|
delete process.env.DATABASE_URL;
|
|
});
|
|
|
|
test('B.4: loadConfigFileOnly returns null when no file exists', async () => {
|
|
delete require.cache[require.resolve('../src/core/config.ts')];
|
|
const { loadConfigFileOnly } = await import('../src/core/config.ts');
|
|
expect(loadConfigFileOnly()).toBeNull();
|
|
});
|
|
});
|
|
|
|
// Lane C.3 — ZE key plumbing
|
|
describe('v0.37 Lane C.3 — ZE key reaches buildGatewayConfig', () => {
|
|
test('CDX2-5+6: buildGatewayConfig maps zeroentropy_api_key into env dict', async () => {
|
|
// process.env wins over config (intentional — operator escape hatch).
|
|
// Unset the env key so the test exercises the config-only path.
|
|
const savedZe = process.env.ZEROENTROPY_API_KEY;
|
|
const savedOai = process.env.OPENAI_API_KEY;
|
|
const savedAnth = process.env.ANTHROPIC_API_KEY;
|
|
const savedOr = process.env.OPENROUTER_API_KEY;
|
|
delete process.env.ZEROENTROPY_API_KEY;
|
|
delete process.env.OPENAI_API_KEY;
|
|
delete process.env.ANTHROPIC_API_KEY;
|
|
delete process.env.OPENROUTER_API_KEY;
|
|
try {
|
|
const { buildGatewayConfig } = await import('../src/cli.ts');
|
|
const cfg = {
|
|
engine: 'pglite' as const,
|
|
zeroentropy_api_key: 'test-ze-key',
|
|
openai_api_key: 'test-oai',
|
|
anthropic_api_key: 'test-anth',
|
|
openrouter_api_key: 'test-or',
|
|
};
|
|
const gwCfg = buildGatewayConfig(cfg as any);
|
|
expect(gwCfg.env?.ZEROENTROPY_API_KEY).toBe('test-ze-key');
|
|
// Regression on the existing two keys.
|
|
expect(gwCfg.env?.OPENAI_API_KEY).toBe('test-oai');
|
|
expect(gwCfg.env?.ANTHROPIC_API_KEY).toBe('test-anth');
|
|
expect(gwCfg.env?.OPENROUTER_API_KEY).toBe('test-or');
|
|
} finally {
|
|
if (savedZe !== undefined) process.env.ZEROENTROPY_API_KEY = savedZe;
|
|
if (savedOai !== undefined) process.env.OPENAI_API_KEY = savedOai;
|
|
if (savedAnth !== undefined) process.env.ANTHROPIC_API_KEY = savedAnth;
|
|
if (savedOr !== undefined) process.env.OPENROUTER_API_KEY = savedOr;
|
|
}
|
|
});
|
|
|
|
test('CDX2-5+6: process.env wins over config (operator escape hatch contract)', async () => {
|
|
const saved = process.env.ZEROENTROPY_API_KEY;
|
|
process.env.ZEROENTROPY_API_KEY = 'env-wins-key';
|
|
try {
|
|
const { buildGatewayConfig } = await import('../src/cli.ts');
|
|
const cfg = { engine: 'pglite' as const, zeroentropy_api_key: 'file-key' };
|
|
const gwCfg = buildGatewayConfig(cfg as any);
|
|
expect(gwCfg.env?.ZEROENTROPY_API_KEY).toBe('env-wins-key');
|
|
} finally {
|
|
if (saved === undefined) delete process.env.ZEROENTROPY_API_KEY;
|
|
else process.env.ZEROENTROPY_API_KEY = saved;
|
|
}
|
|
});
|
|
|
|
test('GBrainConfig type includes zeroentropy_api_key field (TS compile guard)', async () => {
|
|
const { type } = await import('../src/core/config.ts').then(m => ({ type: undefined }));
|
|
// The type-level assertion happens at compile time. If this file
|
|
// compiles, the field exists. Body of the test is a runtime no-op.
|
|
expect(true).toBe(true);
|
|
});
|
|
});
|
|
|
|
// Lane D.1 — engine-kind branching already covered in test/embedding-dim-check.test.ts
|
|
// (extended in same wave). The PGLite branch + Postgres branch + databasePath
|
|
// fallback + no-op-recipe-removal tests live there.
|
|
|
|
// Lane D.2 — embed pre-flight dim mismatch
|
|
describe('v0.37 Lane D.2 — embed pre-flight dim mismatch', () => {
|
|
test('CDX2-9: EmbeddingDimMismatchError is exported + tagged', async () => {
|
|
const { EmbeddingDimMismatchError } = await import('../src/commands/embed.ts');
|
|
expect(typeof EmbeddingDimMismatchError).toBe('function');
|
|
const err = new EmbeddingDimMismatchError('test recipe');
|
|
expect(err).toBeInstanceOf(Error);
|
|
expect(err.kind).toBe('embedding_dim_mismatch');
|
|
expect(err.recipeMessage).toBe('test recipe');
|
|
expect(err.name).toBe('EmbeddingDimMismatchError');
|
|
});
|
|
});
|
|
|
|
// Lane D.4 — sync help dispatch
|
|
describe('v0.37 Lane D.4 — sync --help dispatch', () => {
|
|
test('CDX2-12: sync is in CLI_ONLY_SELF_HELP', async () => {
|
|
// This is a structural test — read the cli.ts source and assert
|
|
// sync appears in the set. Avoids requiring engine wiring.
|
|
const src = readFileSync(join(__dirname, '..', 'src', 'cli.ts'), 'utf-8');
|
|
// Match the CLI_ONLY_SELF_HELP set definition.
|
|
const setMatch = src.match(/const CLI_ONLY_SELF_HELP = new Set\(\[([\s\S]*?)\]\)/);
|
|
expect(setMatch).not.toBeNull();
|
|
const body = setMatch![1];
|
|
expect(body).toContain(`'sync'`);
|
|
});
|
|
});
|
|
|
|
// Deferred-TODO ship: gbrain reinit-pglite
|
|
describe('v0.37 deferred TODO shipped — gbrain reinit-pglite', () => {
|
|
test('reinit-pglite is registered in CLI_ONLY + CLI_ONLY_SELF_HELP', () => {
|
|
const src = readFileSync(join(__dirname, '..', 'src', 'cli.ts'), 'utf-8');
|
|
const onlyMatch = src.match(/const CLI_ONLY = new Set\(\[([\s\S]*?)\]\)/);
|
|
expect(onlyMatch).not.toBeNull();
|
|
expect(onlyMatch![1]).toContain(`'reinit-pglite'`);
|
|
|
|
const selfHelpMatch = src.match(/const CLI_ONLY_SELF_HELP = new Set\(\[([\s\S]*?)\]\)/);
|
|
expect(selfHelpMatch).not.toBeNull();
|
|
expect(selfHelpMatch![1]).toContain(`'reinit-pglite'`);
|
|
});
|
|
|
|
test('reinit-pglite module exports runReinitPglite', async () => {
|
|
const mod = await import('../src/commands/reinit-pglite.ts');
|
|
expect(typeof mod.runReinitPglite).toBe('function');
|
|
});
|
|
|
|
test('embeddingMismatchMessage PGLite branch recommends `gbrain reinit-pglite`', async () => {
|
|
const { embeddingMismatchMessage } = await import('../src/core/embedding-dim-check.ts');
|
|
const msg = embeddingMismatchMessage({
|
|
currentDims: 1536,
|
|
requestedDims: 1280,
|
|
requestedModel: 'zeroentropyai:zembed-1',
|
|
source: 'doctor',
|
|
engineKind: 'pglite',
|
|
databasePath: '/tmp/test.pglite',
|
|
});
|
|
// The one-command path appears before the by-hand recipe.
|
|
expect(msg).toContain('gbrain reinit-pglite --embedding-model zeroentropyai:zembed-1 --embedding-dimensions 1280');
|
|
// The by-hand path is still present as fallback.
|
|
expect(msg).toContain('mv /tmp/test.pglite /tmp/test.pglite.bak');
|
|
// The recommended-section header precedes the by-hand section.
|
|
const recIdx = msg.indexOf('Recommended');
|
|
const handIdx = msg.indexOf('Or by hand');
|
|
expect(recIdx).toBeGreaterThan(0);
|
|
expect(handIdx).toBeGreaterThan(recIdx);
|
|
});
|
|
});
|