Files
gbrain/test/config-get-plane.test.ts
T
78bc2fef09 fix(cli,config,doctor): CLI/config UX wave — config-get file plane, idempotent archive, honest help + doctor text (#2120 #2792 #1175 #1123 #2451) (#2918)
* fix(cli,config,doctor): CLI/config UX wave — config-get file plane, idempotent archive, honest help + doctor text, prefixed model defaults (#2120 #2792 #1175 #1123 #2451)

- config get resolves the file/env plane before the DB plane (runtime
  precedence) and reports provenance on stderr; stdout stays a bare value.
- sources archive distinguishes already-archived (friendly no-op, exit 0)
  from not-found (clear exit-4 error).
- gbrain --help SOURCES block now lists archive/restore/archived/purge/status
  plus a pointer at `sources --help` for the long tail.
- multi_source_drift doctor advice references only real CLI surfaces
  (drops the never-built 'sources rehome'; pins delete to GBRAIN_SOURCE=default).
- #2451 (bare model ids in calibration defaults) verified already fixed +
  tested on master by #2892 — no change needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: satisfy test-isolation gate for wave-B tests

check-test-isolation R1 forbids direct process.env mutation in non-serial
unit tests (env leaks across files sharing a shard process). Route the
GBRAIN_HOME / GBRAIN_CHAT_MODEL / GBRAIN_PGLITE_SNAPSHOT overrides through
the canonical withEnv() helper instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:27:34 -07:00

94 lines
3.8 KiB
TypeScript

/**
* #2120 — `gbrain config get` must resolve the file/env plane, not just the
* DB plane. Pre-fix, `get` called only `engine.getConfig(key)`, so a
* runtime-effective key in ~/.gbrain/config.json reported not-found while
* the runtime happily used it.
*
* Hermetic: GBRAIN_HOME points at a tmp dir (configDir() honors it) via
* withEnv (check-test-isolation R1), and the engine is a getConfig-only
* stub — the `get` path touches nothing else.
*/
import { describe, test, expect, spyOn } from 'bun:test';
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { runConfig } from '../src/commands/config.ts';
import { withEnv } from './helpers/with-env.ts';
import type { BrainEngine } from '../src/core/engine.ts';
const home = mkdtempSync(join(tmpdir(), 'gbrain-config-get-'));
mkdirSync(join(home, '.gbrain'), { recursive: true });
function stubEngine(dbValues: Record<string, string>): BrainEngine {
return {
getConfig: async (key: string) => dbValues[key] ?? null,
} as unknown as BrainEngine;
}
function writeFileConfig(cfg: Record<string, unknown>): void {
writeFileSync(join(home, '.gbrain', 'config.json'), JSON.stringify(cfg));
}
/** Run `config get <key>` with GBRAIN_HOME pinned to the tmp brain and the
* chat-model env overlay cleared, capturing output + exit code. */
async function runGet(
dbValues: Record<string, string>,
key: string,
): Promise<{ logs: string[]; errs: string[]; exit: number | null }> {
const logs: string[] = [];
const errs: string[] = [];
let exit: number | null = null;
const logSpy = spyOn(console, 'log').mockImplementation((...a: unknown[]) => { logs.push(a.join(' ')); });
const errSpy = spyOn(console, 'error').mockImplementation((...a: unknown[]) => { errs.push(a.join(' ')); });
const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => {
exit = code ?? 0;
throw new Error(`EXIT:${code}`);
}) as never);
try {
await withEnv(
{ GBRAIN_HOME: home, GBRAIN_CHAT_MODEL: undefined },
() => runConfig(stubEngine(dbValues), ['get', key]),
);
} catch (e) {
if (!(e as Error).message.startsWith('EXIT:')) throw e;
} finally {
logSpy.mockRestore();
errSpy.mockRestore();
exitSpy.mockRestore();
}
return { logs, errs, exit };
}
describe('#2120 — config get resolves file plane with DB fallback', () => {
test('file-plane key with no DB row is found (the pre-fix not-found bug)', async () => {
writeFileConfig({ engine: 'pglite', chat_model: 'anthropic:claude-sonnet-4-6' });
const { logs, errs, exit } = await runGet({}, 'chat_model');
expect(exit).toBeNull();
expect(logs).toContain('anthropic:claude-sonnet-4-6');
expect(errs.join('\n')).toContain('file/env plane');
});
test('file plane wins over DB plane (matches runtime precedence) and reports the shadow', async () => {
writeFileConfig({ engine: 'pglite', chat_model: 'anthropic:claude-sonnet-4-6' });
const { logs, errs } = await runGet({ chat_model: 'openai:gpt-5' }, 'chat_model');
expect(logs).toContain('anthropic:claude-sonnet-4-6');
expect(logs).not.toContain('openai:gpt-5');
expect(errs.join('\n')).toContain('shadowed');
});
test('DB-plane-only key still resolves (no regression for dotted keys)', async () => {
writeFileConfig({ engine: 'pglite' });
const { logs, errs } = await runGet({ 'search.mode': 'balanced' }, 'search.mode');
expect(logs).toContain('balanced');
expect(errs.join('\n')).toContain('db plane');
});
test('key in neither plane is still not-found (exit 1)', async () => {
writeFileConfig({ engine: 'pglite' });
const { errs, exit } = await runGet({}, 'chat_model');
expect(exit).toBe(1);
expect(errs.join('\n')).toContain('Config key not found: chat_model');
});
});