| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298 |
- import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
- import { mkdtemp, rm, mkdir } from 'node:fs/promises';
- import { join, dirname } from 'node:path';
- import { tmpdir } from 'node:os';
- import {
- readConfig,
- writeConfig,
- createDefaultConfig,
- mergeConfig,
- isYoloMode,
- isAutoBackup,
- getConfigPath,
- } from './config.js';
- // ── getConfigPath ─────────────────────────────────────────────────────────────
- // Pure function — no I/O.
- describe('getConfigPath', () => {
- // ✅ Positive: returns the expected path
- test('returns .oac/config.json under the project root', () => {
- // Arrange
- const projectRoot = '/home/user/my-project';
- // Act
- const result = getConfigPath(projectRoot);
- // Assert
- expect(result).toBe('/home/user/my-project/.oac/config.json');
- });
- // ✅ Positive: works with trailing slash stripped by path.join
- test('handles project roots without trailing slash', () => {
- const result = getConfigPath('/tmp/proj');
- expect(result).toEndWith('/.oac/config.json');
- });
- });
- // ── createDefaultConfig ───────────────────────────────────────────────────────
- // Pure function — no I/O.
- describe('createDefaultConfig', () => {
- // ✅ Positive: returns version "1"
- test('returns a config with version "1"', () => {
- const config = createDefaultConfig();
- expect(config.version).toBe('1');
- });
- // ✅ Positive: yoloMode defaults to false
- test('yoloMode defaults to false', () => {
- const config = createDefaultConfig();
- expect(config.preferences.yoloMode).toBe(false);
- });
- // ✅ Positive: autoBackup defaults to true
- test('autoBackup defaults to true', () => {
- const config = createDefaultConfig();
- expect(config.preferences.autoBackup).toBe(true);
- });
- // ❌ Negative: two calls return independent objects (no shared reference)
- test('returns a new object on each call', () => {
- const a = createDefaultConfig();
- const b = createDefaultConfig();
- // Mutating one should not affect the other
- a.preferences.yoloMode = true;
- expect(b.preferences.yoloMode).toBe(false);
- });
- });
- // ── mergeConfig ───────────────────────────────────────────────────────────────
- // Pure function — no I/O.
- describe('mergeConfig', () => {
- // ✅ Positive: overrides a single preference field
- test('overrides yoloMode when provided', () => {
- // Arrange
- const base = createDefaultConfig();
- // Act
- const merged = mergeConfig(base, { yoloMode: true });
- // Assert
- expect(merged.preferences.yoloMode).toBe(true);
- });
- // ✅ Positive: preserves unspecified preference fields
- test('preserves autoBackup when only yoloMode is overridden', () => {
- const base = createDefaultConfig(); // autoBackup: true
- const merged = mergeConfig(base, { yoloMode: true });
- expect(merged.preferences.autoBackup).toBe(true);
- });
- // ✅ Positive: overrides autoBackup
- test('overrides autoBackup when provided', () => {
- const base = createDefaultConfig();
- const merged = mergeConfig(base, { autoBackup: false });
- expect(merged.preferences.autoBackup).toBe(false);
- });
- // ✅ Positive: overrides both fields simultaneously
- test('overrides both fields when both are provided', () => {
- const base = createDefaultConfig();
- const merged = mergeConfig(base, { yoloMode: true, autoBackup: false });
- expect(merged.preferences.yoloMode).toBe(true);
- expect(merged.preferences.autoBackup).toBe(false);
- });
- // ❌ Negative: does not mutate the base config
- test('does not mutate the base config', () => {
- const base = createDefaultConfig();
- mergeConfig(base, { yoloMode: true });
- expect(base.preferences.yoloMode).toBe(false);
- });
- // ❌ Negative: empty overrides returns equivalent config
- test('empty overrides returns config with same preference values', () => {
- const base = createDefaultConfig();
- const merged = mergeConfig(base, {});
- expect(merged.preferences.yoloMode).toBe(base.preferences.yoloMode);
- expect(merged.preferences.autoBackup).toBe(base.preferences.autoBackup);
- });
- });
- // ── isYoloMode ────────────────────────────────────────────────────────────────
- // Note: isYoloMode also checks process.env.CI — we test both branches.
- describe('isYoloMode', () => {
- // ✅ Positive: returns true when yoloMode preference is true
- test('returns true when config.preferences.yoloMode is true', () => {
- // Arrange
- const config = mergeConfig(createDefaultConfig(), { yoloMode: true });
- // Act & Assert
- // Temporarily clear CI to isolate the preference check
- const savedCI = process.env['CI'];
- delete process.env['CI'];
- try {
- expect(isYoloMode(config)).toBe(true);
- } finally {
- if (savedCI !== undefined) process.env['CI'] = savedCI;
- }
- });
- // ✅ Positive: returns true when CI env var is "true" (even if preference is false)
- test('returns true when process.env.CI is "true"', () => {
- const config = createDefaultConfig(); // yoloMode: false
- const savedCI = process.env['CI'];
- process.env['CI'] = 'true';
- try {
- expect(isYoloMode(config)).toBe(true);
- } finally {
- if (savedCI !== undefined) {
- process.env['CI'] = savedCI;
- } else {
- delete process.env['CI'];
- }
- }
- });
- // ❌ Negative: returns false when yoloMode is false and CI is not set
- test('returns false when yoloMode is false and CI is not "true"', () => {
- const config = createDefaultConfig(); // yoloMode: false
- const savedCI = process.env['CI'];
- delete process.env['CI'];
- try {
- expect(isYoloMode(config)).toBe(false);
- } finally {
- if (savedCI !== undefined) process.env['CI'] = savedCI;
- }
- });
- });
- // ── isAutoBackup ──────────────────────────────────────────────────────────────
- describe('isAutoBackup', () => {
- // ✅ Positive: returns true when autoBackup is true
- test('returns true when autoBackup preference is true', () => {
- const config = createDefaultConfig(); // autoBackup: true
- expect(isAutoBackup(config)).toBe(true);
- });
- // ❌ Negative: returns false when autoBackup is false
- test('returns false when autoBackup preference is false', () => {
- const config = mergeConfig(createDefaultConfig(), { autoBackup: false });
- expect(isAutoBackup(config)).toBe(false);
- });
- });
- // ── readConfig / writeConfig (I/O round-trip) ─────────────────────────────────
- describe('readConfig / writeConfig', () => {
- let tmpDir: string;
- beforeAll(async () => {
- tmpDir = await mkdtemp(join(tmpdir(), 'oac-config-test-'));
- });
- afterAll(async () => {
- await rm(tmpDir, { recursive: true, force: true });
- });
- // ✅ Positive: readConfig returns null when no config file exists
- test('readConfig returns null when config file does not exist', async () => {
- // Arrange — fresh tmpDir has no .oac/config.json
- const emptyDir = join(tmpDir, 'empty');
- // Act
- const result = await readConfig(emptyDir);
- // Assert
- expect(result).toBeNull();
- });
- // ✅ Positive: writeConfig creates .oac/ dir and writes the file
- test('writeConfig creates .oac/ directory and writes config.json', async () => {
- // Arrange
- const projectRoot = join(tmpDir, 'write-test');
- const config = createDefaultConfig();
- // Act
- await writeConfig(projectRoot, config);
- // Assert — file should now exist
- const configPath = getConfigPath(projectRoot);
- expect(await Bun.file(configPath).exists()).toBe(true);
- });
- // ✅ Positive: round-trip — write then read returns the same config
- test('writeConfig then readConfig round-trips the default config', async () => {
- // Arrange
- const projectRoot = join(tmpDir, 'roundtrip-default');
- const original = createDefaultConfig();
- // Act
- await writeConfig(projectRoot, original);
- const read = await readConfig(projectRoot);
- // Assert
- expect(read).not.toBeNull();
- expect(read?.version).toBe('1');
- expect(read?.preferences.yoloMode).toBe(false);
- expect(read?.preferences.autoBackup).toBe(true);
- });
- // ✅ Positive: round-trip preserves non-default preference values
- test('writeConfig then readConfig round-trips a modified config', async () => {
- // Arrange
- const projectRoot = join(tmpDir, 'roundtrip-modified');
- const config = mergeConfig(createDefaultConfig(), { yoloMode: true, autoBackup: false });
- // Act
- await writeConfig(projectRoot, config);
- const read = await readConfig(projectRoot);
- // Assert
- expect(read?.preferences.yoloMode).toBe(true);
- expect(read?.preferences.autoBackup).toBe(false);
- });
- // ✅ Positive: writeConfig is idempotent — second write overwrites first
- test('second writeConfig call overwrites the first', async () => {
- // Arrange
- const projectRoot = join(tmpDir, 'overwrite-test');
- const first = createDefaultConfig();
- const second = mergeConfig(createDefaultConfig(), { yoloMode: true });
- // Act
- await writeConfig(projectRoot, first);
- await writeConfig(projectRoot, second);
- const read = await readConfig(projectRoot);
- // Assert
- expect(read?.preferences.yoloMode).toBe(true);
- });
- // ❌ Negative: readConfig throws for invalid JSON structure
- test('readConfig throws an error when config JSON is structurally invalid', async () => {
- // Arrange — write a config with a bad version field
- const projectRoot = join(tmpDir, 'bad-config');
- const configPath = getConfigPath(projectRoot);
- // Create the .oac/ directory first (Bun.write does not auto-create parent dirs)
- await mkdir(dirname(configPath), { recursive: true });
- // Write invalid config (version must be literal "1")
- await Bun.write(configPath, JSON.stringify({ version: '99', preferences: {} }));
- // Act & Assert
- await expect(readConfig(projectRoot)).rejects.toThrow();
- });
- // ❌ Negative: readConfig throws for missing required fields
- test('readConfig throws when preferences field is missing', async () => {
- // Arrange
- const projectRoot = join(tmpDir, 'missing-prefs');
- const configPath = getConfigPath(projectRoot);
- await mkdir(dirname(configPath), { recursive: true });
- await Bun.write(configPath, JSON.stringify({ version: '1' }));
- // Act & Assert
- await expect(readConfig(projectRoot)).rejects.toThrow();
- });
- // ❌ Negative: readConfig throws for wrong preference types
- test('readConfig throws when preference values have wrong types', async () => {
- // Arrange
- const projectRoot = join(tmpDir, 'wrong-types');
- const configPath = getConfigPath(projectRoot);
- await mkdir(dirname(configPath), { recursive: true });
- await Bun.write(
- configPath,
- JSON.stringify({ version: '1', preferences: { yoloMode: 'yes', autoBackup: 1 } }),
- );
- // Act & Assert
- await expect(readConfig(projectRoot)).rejects.toThrow();
- });
- });
|