import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, rmSync, existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { loadPreferences, savePreferences, validateMinionMode, appendCompletedMigration, loadCompletedMigrations, preferencesPaths, type Preferences, } from '../src/core/preferences.ts'; let origHome: string | undefined; let origGbrainHome: string | undefined; let tmp: string; beforeEach(() => { origHome = process.env.HOME; origGbrainHome = process.env.GBRAIN_HOME; tmp = mkdtempSync(join(tmpdir(), 'gbrain-prefs-test-')); // preferences.ts's gbrainDir() returns `$HOME/.gbrain` when GBRAIN_HOME // is unset. Test fixtures write to `$tmp/.gbrain/...`, so set HOME only // and clear GBRAIN_HOME — setting GBRAIN_HOME would route prefs to $tmp // directly (no .gbrain suffix), which doesn't match the fixture layout. process.env.HOME = tmp; delete process.env.GBRAIN_HOME; }); afterEach(() => { if (origHome === undefined) delete process.env.HOME; else process.env.HOME = origHome; if (origGbrainHome === undefined) delete process.env.GBRAIN_HOME; else process.env.GBRAIN_HOME = origGbrainHome; try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ } }); describe('validateMinionMode', () => { test('accepts always / pain_triggered / off', () => { expect(() => validateMinionMode('always')).not.toThrow(); expect(() => validateMinionMode('pain_triggered')).not.toThrow(); expect(() => validateMinionMode('off')).not.toThrow(); }); test('rejects bogus string with clear allowed list', () => { expect(() => validateMinionMode('bogus')).toThrow(/always.*pain_triggered.*off/); }); test('rejects non-string values', () => { expect(() => validateMinionMode(42)).toThrow(); expect(() => validateMinionMode(null)).toThrow(); expect(() => validateMinionMode(undefined)).toThrow(); }); }); describe('loadPreferences', () => { test('returns empty object when file is missing', () => { expect(loadPreferences()).toEqual({}); }); test('parses existing JSON file', () => { mkdirSync(join(tmp, '.gbrain'), { recursive: true }); writeFileSync( join(tmp, '.gbrain', 'preferences.json'), JSON.stringify({ minion_mode: 'always', set_in_version: '0.11.0' }), ); expect(loadPreferences()).toEqual({ minion_mode: 'always', set_in_version: '0.11.0' }); }); test('throws on malformed JSON so callers can surface it', () => { mkdirSync(join(tmp, '.gbrain'), { recursive: true }); writeFileSync(join(tmp, '.gbrain', 'preferences.json'), '{not json'); expect(() => loadPreferences()).toThrow(); }); }); describe('savePreferences', () => { test('writes file with 0o600 perms', () => { savePreferences({ minion_mode: 'pain_triggered' }); const path = preferencesPaths.file(); expect(existsSync(path)).toBe(true); const mode = statSync(path).mode & 0o777; expect(mode).toBe(0o600); }); test('round-trip preserves unknown keys for forward-compat', () => { const prefs: Preferences = { minion_mode: 'always', set_at: '2026-04-18T00:00:00Z', set_in_version: '0.11.0', // deliberately unknown key — future version may add this future_feature_flag: { enabled: true, setting: 42 }, }; savePreferences(prefs); expect(loadPreferences()).toEqual(prefs); }); test('rejects invalid minion_mode on save', () => { expect(() => savePreferences({ minion_mode: 'bogus' as any })).toThrow(); }); test('creates ~/.gbrain directory if missing', () => { // Confirm .gbrain doesn't exist yet expect(existsSync(join(tmp, '.gbrain'))).toBe(false); savePreferences({ minion_mode: 'off' }); expect(existsSync(join(tmp, '.gbrain'))).toBe(true); }); test('concurrent save + load: reader never sees a half-written file', () => { // Save a valid file, then save a new one. In the middle, the file should // always be parseable (atomic rename guarantees this). savePreferences({ minion_mode: 'always' }); const firstLoad = loadPreferences(); expect(firstLoad.minion_mode).toBe('always'); savePreferences({ minion_mode: 'pain_triggered' }); const secondLoad = loadPreferences(); expect(secondLoad.minion_mode).toBe('pain_triggered'); }); test('cleans up temp directory used for atomic write', () => { savePreferences({ minion_mode: 'off' }); const gbrainDir = join(tmp, '.gbrain'); // Walk children; nothing should remain except preferences.json (plus maybe subdirs // created by other code, but for this test the only thing we wrote is prefs). const { readdirSync } = require('fs'); const entries = readdirSync(gbrainDir); // Only preferences.json should remain; no .prefs-tmp-* directories left over. expect(entries.filter((e: string) => e.startsWith('.prefs-tmp-'))).toEqual([]); expect(entries).toContain('preferences.json'); }); }); describe('appendCompletedMigration', () => { test('creates migrations dir and appends valid JSONL', () => { appendCompletedMigration({ version: '0.11.0', status: 'complete', mode: 'always' }); const path = preferencesPaths.completedJsonl(); expect(existsSync(path)).toBe(true); const lines = readFileSync(path, 'utf-8').split('\n').filter(l => l.trim()); expect(lines.length).toBe(1); const parsed = JSON.parse(lines[0]); expect(parsed.version).toBe('0.11.0'); expect(parsed.status).toBe('complete'); expect(parsed.mode).toBe('always'); expect(parsed.ts).toBeTruthy(); }); test('appends instead of overwriting', () => { appendCompletedMigration({ version: '0.11.0', status: 'partial', apply_migrations_pending: true }); appendCompletedMigration({ version: '0.11.0', status: 'complete', mode: 'always' }); const lines = readFileSync(preferencesPaths.completedJsonl(), 'utf-8').split('\n').filter(l => l.trim()); expect(lines.length).toBe(2); }); test('rejects entries with no version', () => { expect(() => appendCompletedMigration({ status: 'complete' } as any)).toThrow(/version/); }); test('rejects entries with invalid status', () => { expect(() => appendCompletedMigration({ version: '0.11.0', status: 'done' as any })).toThrow(/status/); }); test('auto-populates ts when not provided', () => { const before = Date.now(); appendCompletedMigration({ version: '0.11.0', status: 'complete' }); const parsed = JSON.parse(readFileSync(preferencesPaths.completedJsonl(), 'utf-8').trim()); const ts = Date.parse(parsed.ts); expect(ts).toBeGreaterThanOrEqual(before); }); test('preserves caller-provided ts', () => { appendCompletedMigration({ version: '0.11.0', status: 'complete', ts: '2020-01-01T00:00:00Z' }); const parsed = JSON.parse(readFileSync(preferencesPaths.completedJsonl(), 'utf-8').trim()); expect(parsed.ts).toBe('2020-01-01T00:00:00Z'); }); }); describe('loadCompletedMigrations', () => { test('returns empty when file is missing', () => { expect(loadCompletedMigrations()).toEqual([]); }); test('parses valid JSONL lines', () => { appendCompletedMigration({ version: '0.10.0', status: 'complete' }); appendCompletedMigration({ version: '0.11.0', status: 'partial' }); const entries = loadCompletedMigrations(); expect(entries.length).toBe(2); expect(entries[0].version).toBe('0.10.0'); expect(entries[1].status).toBe('partial'); }); test('tolerates malformed lines with a warning, continuing past them', () => { const dir = join(tmp, '.gbrain', 'migrations'); mkdirSync(dir, { recursive: true }); // Write a file with a good line, a malformed line, and another good line. writeFileSync( join(dir, 'completed.jsonl'), [ JSON.stringify({ version: '0.10.0', status: 'complete' }), '{this is not valid json', JSON.stringify({ version: '0.11.0', status: 'complete' }), '', ].join('\n'), ); const entries = loadCompletedMigrations(); expect(entries.length).toBe(2); expect(entries[0].version).toBe('0.10.0'); expect(entries[1].version).toBe('0.11.0'); }); });