Files
gbrain/test/schema-cli.test.ts
ea6cb025be fix(schema,minions): truthful bundled pack inspection + config-aware subagent auth (#3110)
* fix(schema): make bundled pack inspection truthful (#2029)

Two live bugs:
- parseYamlMini had no block-scalar support, so a 'description: |' swallowed
  every following top-level key — the active gbrain-recommended pack loaded
  with 0 page types. Add parseBlockScalar for |/|-/|+ and >/>-/>+ in both
  mapping and sequence-sibling positions.
- The bundled-pack list was hand-copied in three places (operations.ts had 2
  names, mutate.ts had 3, load-active.ts had 7). New single registry
  src/core/schema-pack/bundled.ts carries all 7 shipped packs; every
  consumer derives from it.

Takeover of #2029, rebased onto master (schema.ts hunks already landed).

Co-authored-by: JiraiyaETH <JiraiyaETH@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(minions): subagent default client resolves config-stored Anthropic key (#2048)

The legacy subagent path constructed a bare new Anthropic() (env-only), so
launchd/MCP workers whose key lives in gbrain config (anthropic_api_key)
failed auth. anthropic-key.ts now exports resolveAnthropicKey() (env first,
then config; hasAnthropicKey delegates) and makeSubagentHandler passes it as
apiKey.

Partial takeover of #2048 — only the auth patch; the path patches were
superseded by the outputRoot mechanism (#2415).

Co-authored-by: JiraiyaETH <JiraiyaETH@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(schema): point bundled-registry test at bundled.ts source of truth

The bundled pack list moved from load-active.ts to bundled.ts in the
truthful-inspection refactor; the T4 registry test still grepped
load-active.ts source. Assert BUNDLED_PACK_NAMES directly instead.

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

* fix(schema): block scalars keep '#' as literal content

Inside a YAML block scalar '#' is content, not a comment; parseBlockScalar
was routing lines through stripComment/isBlank, truncating descriptions
like 'see issue #2029' and blanking comment-looking lines. Use the raw
line inside the scalar. Adds a pinning test.

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

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: JiraiyaETH <JiraiyaETH@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:13:51 -07:00

220 lines
8.8 KiB
TypeScript

// v0.38 Phase C: gbrain schema CLI smoke tests.
//
// Tests the runSchema dispatch + each subcommand's output shape via
// the public CLI entrypoint. Hermetic — uses Bun's subprocess to run
// the CLI like a user would.
import { describe, expect, test, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const REPO_ROOT = join(import.meta.dir, '..');
// Default-isolated GBRAIN_HOME for every gbrain() call. Without this,
// tests that read `~/.gbrain/config.json` inherit the developer's real
// brain config — and sibling Conductor worktrees writing to the same
// config (e.g. via `schema use` or `config set` during their own tests)
// cause flakes (the failing test pre-fix saw `schema_pack: "gbrain-base-v2"`
// from another worktree, which doesn't exist in the bundle, and got
// exit 1 instead of the asserted 0).
let DEFAULT_GBRAIN_HOME: string;
beforeAll(() => {
DEFAULT_GBRAIN_HOME = mkdtempSync(join(tmpdir(), 'gbrain-schema-cli-default-'));
});
afterAll(() => {
rmSync(DEFAULT_GBRAIN_HOME, { recursive: true, force: true });
});
function gbrain(
args: string[],
extraEnv: Record<string, string> = {},
): { stdout: string; stderr: string; code: number } {
// bun's spawnSync does NOT inherit env mutations done via process.env = ...,
// so pass env explicitly. CLAUDE.md flags this pattern as load-bearing for
// any subprocess test that needs GBRAIN_HOME isolation.
const env = {
...process.env,
GBRAIN_DATABASE_URL: '',
DATABASE_URL: '',
GBRAIN_HOME: DEFAULT_GBRAIN_HOME,
...extraEnv,
};
const result = spawnSync('bun', ['run', 'src/cli.ts', ...args], {
cwd: REPO_ROOT,
encoding: 'utf-8',
env,
});
return {
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
code: result.status ?? -1,
};
}
describe('gbrain schema CLI (Phase C)', () => {
test('schema with no subcommand shows help text', () => {
// Note: `schema --help` is intercepted by the CLI's parent help system
// and prints generic help (`gbrain --help` for full command list). The
// schema-specific help fires when no subcommand is provided.
const r = gbrain(['schema']);
expect(r.stdout + r.stderr).toMatch(/schema|active|list|show|validate|use/i);
});
test('schema list shows all bundled packs', () => {
const r = gbrain(['schema', 'list']);
expect(r.code).toBe(0);
expect(r.stdout).toContain('Bundled packs:');
expect(r.stdout).toContain('gbrain-base');
expect(r.stdout).toContain('gbrain-recommended');
expect(r.stdout).toContain('gbrain-base-v2');
expect(r.stdout).toContain('gbrain-investor');
});
test('schema show gbrain-base prints manifest details', () => {
const r = gbrain(['schema', 'show', 'gbrain-base']);
expect(r.code).toBe(0);
expect(r.stdout).toContain('gbrain-base v1.0.0');
// v0.41.11.0: page types extended from 22 to 24 by promoting
// `conversation` and `atom` into gbrain-base.
// v0.41.23.0: extended to 25 by adding `extract_receipt` for the
// unified extract receipt-writer surface (D-EXTRACT-19 belt+suspenders).
// v0.42.56.0 (#2390): extended to 27 by adding the Life Chronicle
// `event` + `diary` temporal types (life/events/, life/diary/).
expect(r.stdout).toContain('Page types (27)');
expect(r.stdout).toContain('event :: temporal');
expect(r.stdout).toContain('diary :: temporal');
expect(r.stdout).toContain('Link verbs (12)');
expect(r.stdout).toContain('Takes kinds: fact, take, bet, hunch');
expect(r.stdout).toContain('person :: entity');
expect(r.stdout).toContain('company :: entity');
});
test('schema validate gbrain-base passes', () => {
const r = gbrain(['schema', 'validate', 'gbrain-base']);
expect(r.code).toBe(0);
expect(r.stdout).toContain('✓');
expect(r.stdout).toContain('valid manifest');
});
test('schema show/validate exposes bundled gbrain-recommended', () => {
const show = gbrain(['schema', 'show', 'gbrain-recommended']);
expect(show.code).toBe(0);
expect(show.stdout).toContain('gbrain-recommended v1.0.0');
expect(show.stdout).toContain('Page types (');
expect(show.stdout).toContain('meeting :: temporal');
const validate = gbrain(['schema', 'validate', 'gbrain-recommended']);
expect(validate.code).toBe(0);
expect(validate.stdout).toContain('valid manifest');
});
test('schema show exposes bundled gbrain-base-v2 successor pack', () => {
const r = gbrain(['schema', 'show', 'gbrain-base-v2']);
expect(r.code).toBe(0);
expect(r.stdout).toContain('gbrain-base-v2 v1.0.0');
expect(r.stdout).toContain('Page types (');
expect(r.stdout).toContain('Link verbs (14)');
});
test('schema active loads configured gbrain-recommended with real types', () => {
const home = mkdtempSync(join(tmpdir(), 'gbrain-schema-active-recommended-'));
try {
mkdirSync(join(home, '.gbrain'), { recursive: true });
writeFileSync(join(home, '.gbrain', 'config.json'), JSON.stringify({ schema_pack: 'gbrain-recommended' }), 'utf-8');
const r = gbrain(['schema', 'active'], { GBRAIN_HOME: home });
expect(r.code).toBe(0);
expect(r.stdout).toContain('Active pack: gbrain-recommended');
expect(r.stdout).not.toContain('Page types: 0');
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('schema active reports default resolution', () => {
const r = gbrain(['schema', 'active']);
expect(r.code).toBe(0);
expect(r.stdout).toContain('Active pack:');
expect(r.stdout).toContain('Pack identity:');
});
test('schema show unknown-pack errors with hint', () => {
const r = gbrain(['schema', 'show', 'nonexistent-pack']);
expect(r.code).not.toBe(0);
expect(r.stderr).toContain('Unknown pack');
expect(r.stderr).toContain('schema list');
});
test('unknown subcommand exits with hint', () => {
const r = gbrain(['schema', 'frobnicate']);
expect(r.code).toBe(2);
expect(r.stderr).toContain('Unknown schema subcommand');
});
test('schema use without arg shows usage hint', () => {
const r = gbrain(['schema', 'use']);
expect(r.code).toBe(2);
expect(r.stderr).toContain('Usage:');
});
});
describe('gbrain schema use (Phase C, gap-fill T3)', () => {
let home: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'gbrain-schema-use-'));
});
afterEach(() => {
rmSync(home, { recursive: true, force: true });
});
test('writes schema_pack to ~/.gbrain/config.json on happy path', () => {
const r = gbrain(['schema', 'use', 'gbrain-base'], { GBRAIN_HOME: home });
expect(r.code).toBe(0);
expect(r.stdout).toContain('Active schema pack set to: gbrain-base');
expect(r.stdout).toContain('schema active');
const cfgPath = join(home, '.gbrain', 'config.json');
expect(existsSync(cfgPath)).toBe(true);
const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
expect(cfg.schema_pack).toBe('gbrain-base');
});
test('preserves pre-existing config fields when writing schema_pack', () => {
// Pre-seed a config with engine + a custom key so the merge preserves them.
mkdirSync(join(home, '.gbrain'), { recursive: true });
const cfgPath = join(home, '.gbrain', 'config.json');
writeFileSync(cfgPath, JSON.stringify({ engine: 'pglite', openai_key: 'sk-fake' }, null, 2), 'utf-8');
const r = gbrain(['schema', 'use', 'gbrain-base'], { GBRAIN_HOME: home });
expect(r.code).toBe(0);
const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
expect(cfg.engine).toBe('pglite');
expect(cfg.openai_key).toBe('sk-fake');
expect(cfg.schema_pack).toBe('gbrain-base');
});
test('overwrites prior schema_pack value on re-run', () => {
// First set a placeholder, then overwrite via the CLI.
mkdirSync(join(home, '.gbrain'), { recursive: true });
const cfgPath = join(home, '.gbrain', 'config.json');
writeFileSync(cfgPath, JSON.stringify({ engine: 'pglite', schema_pack: 'something-else' }, null, 2), 'utf-8');
const r = gbrain(['schema', 'use', 'gbrain-base'], { GBRAIN_HOME: home });
expect(r.code).toBe(0);
const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
expect(cfg.schema_pack).toBe('gbrain-base');
});
test('unknown pack rejected with exit 1 + paste-ready hint', () => {
const r = gbrain(['schema', 'use', 'no-such-pack-xyz'], { GBRAIN_HOME: home });
expect(r.code).toBe(1);
expect(r.stderr).toContain('Unknown pack');
expect(r.stderr).toContain('schema list');
// Importantly: a failed `use` must NOT have written a config.
const cfgPath = join(home, '.gbrain', 'config.json');
expect(existsSync(cfgPath)).toBe(false);
});
});