mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
import { describe, test, expect, afterEach } from 'bun:test';
|
|
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
|
|
import { join, isAbsolute } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import {
|
|
loadMounts,
|
|
validateMountId,
|
|
BrainRegistry,
|
|
DuplicateMountPathError,
|
|
UnknownBrainError,
|
|
HOST_BRAIN_ID,
|
|
type MountsFile,
|
|
type MountEntry,
|
|
} from '../src/core/brain-registry.ts';
|
|
import { GBrainError } from '../src/core/types.ts';
|
|
|
|
/** Create a temp dir + write a mounts.json into it. Returns the path. */
|
|
function tempMountsFile(contents: unknown): string {
|
|
const dir = mkdtempSync(join(tmpdir(), 'brain-registry-'));
|
|
const path = join(dir, 'mounts.json');
|
|
writeFileSync(path, typeof contents === 'string' ? contents : JSON.stringify(contents));
|
|
return path;
|
|
}
|
|
|
|
const toCleanup: string[] = [];
|
|
function track(p: string): string {
|
|
toCleanup.push(p);
|
|
return p;
|
|
}
|
|
afterEach(() => {
|
|
while (toCleanup.length > 0) {
|
|
const p = toCleanup.pop();
|
|
if (!p) continue;
|
|
try {
|
|
rmSync(p, { recursive: true, force: true });
|
|
} catch { /* best effort */ }
|
|
}
|
|
});
|
|
|
|
describe('validateMountId', () => {
|
|
test('accepts kebab-case ids', () => {
|
|
expect(validateMountId('yc-media')).toBe('yc-media');
|
|
expect(validateMountId('garrys-list')).toBe('garrys-list');
|
|
expect(validateMountId('a')).toBe('a');
|
|
expect(validateMountId('yc1')).toBe('yc1');
|
|
});
|
|
|
|
test('rejects empty / non-string', () => {
|
|
expect(() => validateMountId('')).toThrow(GBrainError);
|
|
expect(() => validateMountId(null as unknown as string)).toThrow(GBrainError);
|
|
expect(() => validateMountId(undefined as unknown as string)).toThrow(GBrainError);
|
|
expect(() => validateMountId(42 as unknown as string)).toThrow(GBrainError);
|
|
});
|
|
|
|
test('rejects reserved host id', () => {
|
|
expect(() => validateMountId('host')).toThrow(/Reserved/);
|
|
});
|
|
|
|
test('rejects invalid patterns', () => {
|
|
expect(() => validateMountId('UPPER')).toThrow();
|
|
expect(() => validateMountId('has space')).toThrow();
|
|
expect(() => validateMountId('-leading')).toThrow();
|
|
expect(() => validateMountId('trailing-')).toThrow();
|
|
expect(() => validateMountId('has_underscore')).toThrow();
|
|
expect(() => validateMountId('a'.repeat(33))).toThrow();
|
|
});
|
|
});
|
|
|
|
describe('loadMounts — file-level parsing', () => {
|
|
test('returns empty array when mounts.json is absent', () => {
|
|
const path = join(tmpdir(), `nonexistent-${Date.now()}.json`);
|
|
expect(loadMounts(path)).toEqual([]);
|
|
});
|
|
|
|
test('throws on malformed JSON', () => {
|
|
const path = track(tempMountsFile('{ not valid json'));
|
|
expect(() => loadMounts(path)).toThrow(/Malformed mounts.json/);
|
|
});
|
|
|
|
test('throws on unsupported version', () => {
|
|
const path = track(tempMountsFile({ version: 99, mounts: [] }));
|
|
expect(() => loadMounts(path)).toThrow(/Unsupported mounts.json version: 99/);
|
|
});
|
|
|
|
test('throws when mounts is not an array', () => {
|
|
const path = track(tempMountsFile({ version: 1, mounts: 'not-an-array' }));
|
|
expect(() => loadMounts(path)).toThrow(/must be an array/);
|
|
});
|
|
|
|
test('throws when top-level is not an object', () => {
|
|
const path = track(tempMountsFile([1, 2, 3]));
|
|
expect(() => loadMounts(path)).toThrow(/must be a JSON object/);
|
|
});
|
|
});
|
|
|
|
describe('loadMounts — entry validation', () => {
|
|
test('accepts a minimal pglite entry', () => {
|
|
const path = track(tempMountsFile({
|
|
version: 1,
|
|
mounts: [{ id: 'yc-media', path: '/tmp/yc-media', engine: 'pglite', database_path: '/tmp/yc-media/.pg' }],
|
|
}));
|
|
const mounts = loadMounts(path);
|
|
expect(mounts).toHaveLength(1);
|
|
expect(mounts[0].id).toBe('yc-media');
|
|
expect(mounts[0].engine).toBe('pglite');
|
|
expect(mounts[0].enabled).toBe(true); // default
|
|
});
|
|
|
|
test('accepts a postgres entry', () => {
|
|
const path = track(tempMountsFile({
|
|
version: 1,
|
|
mounts: [{
|
|
id: 'yc-politics', path: '/tmp/yc-politics', engine: 'postgres',
|
|
database_url: 'postgresql://localhost/luther',
|
|
}],
|
|
}));
|
|
const mounts = loadMounts(path);
|
|
expect(mounts[0].database_url).toBe('postgresql://localhost/luther');
|
|
});
|
|
|
|
test('resolves paths to absolute form', () => {
|
|
const path = track(tempMountsFile({
|
|
version: 1,
|
|
mounts: [{ id: 'a', path: '/tmp/relative-test', engine: 'pglite', database_path: '/tmp/a/.pg' }],
|
|
}));
|
|
const mounts = loadMounts(path);
|
|
// loadMounts resolve()s the path: 'C:\…' on win32, so a leading-'/'
|
|
// check is the wrong absoluteness test.
|
|
expect(isAbsolute(mounts[0].path)).toBe(true);
|
|
});
|
|
|
|
test('enabled=false is preserved', () => {
|
|
const path = track(tempMountsFile({
|
|
version: 1,
|
|
mounts: [{
|
|
id: 'disabled-mount', path: '/tmp/disabled', engine: 'pglite',
|
|
database_path: '/tmp/disabled/.pg', enabled: false,
|
|
}],
|
|
}));
|
|
const mounts = loadMounts(path);
|
|
expect(mounts[0].enabled).toBe(false);
|
|
});
|
|
|
|
test('rejects duplicate ids', () => {
|
|
const path = track(tempMountsFile({
|
|
version: 1,
|
|
mounts: [
|
|
{ id: 'dup', path: '/tmp/a', engine: 'pglite', database_path: '/tmp/a/.pg' },
|
|
{ id: 'dup', path: '/tmp/b', engine: 'pglite', database_path: '/tmp/b/.pg' },
|
|
],
|
|
}));
|
|
expect(() => loadMounts(path)).toThrow(/duplicate id "dup"/);
|
|
});
|
|
|
|
test('rejects duplicate paths (Codex finding #9)', () => {
|
|
const path = track(tempMountsFile({
|
|
version: 1,
|
|
mounts: [
|
|
{ id: 'first', path: '/tmp/shared', engine: 'pglite', database_path: '/tmp/shared/.pg' },
|
|
{ id: 'second', path: '/tmp/shared', engine: 'pglite', database_path: '/tmp/shared/.pg2' },
|
|
],
|
|
}));
|
|
expect(() => loadMounts(path)).toThrow(DuplicateMountPathError);
|
|
});
|
|
|
|
test('rejects entry missing id', () => {
|
|
const path = track(tempMountsFile({
|
|
version: 1, mounts: [{ path: '/tmp/x', engine: 'pglite', database_path: '/tmp/x/.pg' }],
|
|
}));
|
|
expect(() => loadMounts(path)).toThrow(/Invalid mounts\[0\].id/);
|
|
});
|
|
|
|
test('rejects entry missing path', () => {
|
|
const path = track(tempMountsFile({
|
|
version: 1, mounts: [{ id: 'no-path', engine: 'pglite', database_path: '/tmp/x/.pg' }],
|
|
}));
|
|
expect(() => loadMounts(path)).toThrow(/path is required/);
|
|
});
|
|
|
|
test('rejects invalid engine kind', () => {
|
|
const path = track(tempMountsFile({
|
|
version: 1, mounts: [{ id: 'bad', path: '/tmp/b', engine: 'sqlite' }],
|
|
}));
|
|
expect(() => loadMounts(path)).toThrow(/engine must be "postgres" or "pglite"/);
|
|
});
|
|
|
|
test('rejects postgres without database_url', () => {
|
|
const path = track(tempMountsFile({
|
|
version: 1, mounts: [{ id: 'pg-no-url', path: '/tmp/p', engine: 'postgres' }],
|
|
}));
|
|
expect(() => loadMounts(path)).toThrow(/postgres mount requires database_url/);
|
|
});
|
|
|
|
test('rejects pglite without database_path or url', () => {
|
|
const path = track(tempMountsFile({
|
|
version: 1, mounts: [{ id: 'pgl-no-path', path: '/tmp/p', engine: 'pglite' }],
|
|
}));
|
|
expect(() => loadMounts(path)).toThrow(/pglite mount requires database_path/);
|
|
});
|
|
|
|
test('rejects reserved host id', () => {
|
|
const path = track(tempMountsFile({
|
|
version: 1, mounts: [{ id: 'host', path: '/tmp/h', engine: 'pglite', database_path: '/tmp/h/.pg' }],
|
|
}));
|
|
expect(() => loadMounts(path)).toThrow(/Reserved/);
|
|
});
|
|
});
|
|
|
|
describe('BrainRegistry — resolution', () => {
|
|
test('listBrainIds includes host + enabled mounts only', () => {
|
|
const mounts: MountEntry[] = [
|
|
{ id: 'a', path: '/tmp/a', engine: 'pglite', database_path: '/tmp/a/.pg', enabled: true },
|
|
{ id: 'b', path: '/tmp/b', engine: 'pglite', database_path: '/tmp/b/.pg', enabled: false },
|
|
{ id: 'c', path: '/tmp/c', engine: 'pglite', database_path: '/tmp/c/.pg', enabled: true },
|
|
];
|
|
const reg = new BrainRegistry(mounts);
|
|
expect(reg.listBrainIds()).toEqual([HOST_BRAIN_ID, 'a', 'c']);
|
|
});
|
|
|
|
test('disabled mount → UnknownBrainError', async () => {
|
|
const reg = new BrainRegistry([
|
|
{ id: 'disabled', path: '/tmp/d', engine: 'pglite', database_path: '/tmp/d/.pg', enabled: false },
|
|
]);
|
|
await expect(reg.getBrain('disabled')).rejects.toBeInstanceOf(UnknownBrainError);
|
|
});
|
|
|
|
test('unknown id → UnknownBrainError with available list', async () => {
|
|
const reg = new BrainRegistry([
|
|
{ id: 'yc-media', path: '/tmp/m', engine: 'pglite', database_path: '/tmp/m/.pg', enabled: true },
|
|
]);
|
|
try {
|
|
await reg.getBrain('nonexistent');
|
|
throw new Error('expected throw');
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(UnknownBrainError);
|
|
if (e instanceof GBrainError) {
|
|
expect(e.cause_description).toContain('yc-media');
|
|
}
|
|
}
|
|
});
|
|
|
|
test('listMounts returns only enabled mounts', () => {
|
|
const reg = new BrainRegistry([
|
|
{ id: 'on', path: '/tmp/on', engine: 'pglite', database_path: '/tmp/on/.pg', enabled: true },
|
|
{ id: 'off', path: '/tmp/off', engine: 'pglite', database_path: '/tmp/off/.pg', enabled: false },
|
|
]);
|
|
const listed = reg.listMounts();
|
|
expect(listed).toHaveLength(1);
|
|
expect(listed[0].id).toBe('on');
|
|
});
|
|
|
|
test('disconnectAll on empty registry is idempotent', async () => {
|
|
const reg = new BrainRegistry([]);
|
|
await reg.disconnectAll();
|
|
await reg.disconnectAll(); // second call must not throw
|
|
});
|
|
});
|
|
|
|
describe('BrainRegistry — lazy init', () => {
|
|
test('getBrain does not connect until called', () => {
|
|
const reg = new BrainRegistry([
|
|
{ id: 'lazy', path: '/tmp/lazy', engine: 'pglite', database_path: '/tmp/lazy/.pg', enabled: true },
|
|
]);
|
|
// No assertion on engine state: we just prove the constructor returned
|
|
// without attempting to touch the filesystem. If init were eager, the
|
|
// constructor would throw on the missing database_path.
|
|
expect(reg.listBrainIds()).toContain('lazy');
|
|
});
|
|
|
|
test('empty/null/undefined id routes to host', async () => {
|
|
// We can't actually call getBrain('') without a host config, so we just
|
|
// verify the routing logic by observing the default-branch path. This
|
|
// test proves the fall-through to HOST_BRAIN_ID happens before any
|
|
// lookup, not that host init actually succeeds.
|
|
//
|
|
// Hermeticity: dev machines often have a real ~/.gbrain/config.json
|
|
// (the maintainer's own brain). Without GBRAIN_HOME isolation, the
|
|
// host-init path RESOLVES successfully on those machines instead of
|
|
// rejecting, breaking the `rejects.not.toBeInstanceOf` assertion. Pin
|
|
// GBRAIN_HOME to a guaranteed-empty tempdir so host-init has nothing
|
|
// to find and fails loudly (which is exactly the error the assertion
|
|
// wants — not UnknownBrainError, but ALSO not a successful resolve).
|
|
const isolatedHome = mkdtempSync(join(tmpdir(), 'brain-registry-home-'));
|
|
track(isolatedHome);
|
|
const savedHome = process.env.GBRAIN_HOME;
|
|
process.env.GBRAIN_HOME = isolatedHome;
|
|
try {
|
|
const reg = new BrainRegistry([]);
|
|
// Expect the host-init path to be attempted (it'll fail on missing
|
|
// <isolated>/.gbrain/config.json, but the error will come from
|
|
// initHostBrain, not UnknownBrainError — proving routing hit host).
|
|
await expect(reg.getBrain(null)).rejects.not.toBeInstanceOf(UnknownBrainError);
|
|
await expect(reg.getBrain(undefined)).rejects.not.toBeInstanceOf(UnknownBrainError);
|
|
await expect(reg.getBrain('')).rejects.not.toBeInstanceOf(UnknownBrainError);
|
|
} finally {
|
|
if (savedHome !== undefined) process.env.GBRAIN_HOME = savedHome;
|
|
else delete process.env.GBRAIN_HOME;
|
|
}
|
|
});
|
|
});
|