Files
fission-ai__openspec/test/core/config-schema.test.ts
Tabish Bidiwale 971f8ca4a3 feat(cli): add openspec config command for global configuration management (#382)
* feat(cli): add openspec config command for global configuration management

Implements the `openspec config` command with subcommands:
- `path`: Show config file location
- `list [--json]`: Show all current settings
- `get <key>`: Get a specific value (raw output for scripting)
- `set <key> <value> [--string]`: Set a value with auto type coercion
- `unset <key>`: Remove a key (revert to default)
- `reset --all [-y]`: Reset configuration to defaults
- `edit`: Open config in $EDITOR/$VISUAL

Key features:
- Dot notation for nested key access (e.g., featureFlags.someFlag)
- Auto type coercion (true/false → boolean, numbers → number)
- --string flag to force string storage
- Zod schema validation with unknown field passthrough
- Reserved --scope flag for future project-local config
- Windows-compatible editor spawning with proper path quoting
- Shell completion registry integration

* test(config): add additional unit tests for validation and coercion

- Add tests for unknown fields with various types
- Add test to verify error message path for featureFlags
- Add test for number values rejection in featureFlags
- Add config set simulation tests to verify full coerce → set → validate flow

* fix(config): avoid shell parsing in config edit to handle paths with spaces

Use spawn with shell: false and pass configPath as an argument instead
of building a shell command string. This correctly handles spaces in
both the EDITOR path and config file path on all platforms.

* chore(openspec): archive add-config-command and create cli-config spec

Move completed change to archive and apply spec deltas to create
the cli-config specification documenting the config command interface.

* Validate config keys on set
2025-12-22 18:29:25 +11:00

341 lines
11 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
getNestedValue,
setNestedValue,
deleteNestedValue,
coerceValue,
formatValueYaml,
validateConfig,
GlobalConfigSchema,
DEFAULT_CONFIG,
} from '../../src/core/config-schema.js';
describe('config-schema', () => {
describe('getNestedValue', () => {
it('should get a top-level value', () => {
const obj = { foo: 'bar' };
expect(getNestedValue(obj, 'foo')).toBe('bar');
});
it('should get a nested value with dot notation', () => {
const obj = { a: { b: { c: 'deep' } } };
expect(getNestedValue(obj, 'a.b.c')).toBe('deep');
});
it('should return undefined for non-existent path', () => {
const obj = { foo: 'bar' };
expect(getNestedValue(obj, 'baz')).toBeUndefined();
});
it('should return undefined for non-existent nested path', () => {
const obj = { a: { b: 'value' } };
expect(getNestedValue(obj, 'a.b.c')).toBeUndefined();
});
it('should return undefined when traversing through null', () => {
const obj = { a: null };
expect(getNestedValue(obj as Record<string, unknown>, 'a.b')).toBeUndefined();
});
it('should return undefined when traversing through primitive', () => {
const obj = { a: 'string' };
expect(getNestedValue(obj, 'a.b')).toBeUndefined();
});
it('should get object values', () => {
const obj = { a: { b: 'value' } };
expect(getNestedValue(obj, 'a')).toEqual({ b: 'value' });
});
it('should handle array values', () => {
const obj = { arr: [1, 2, 3] };
expect(getNestedValue(obj, 'arr')).toEqual([1, 2, 3]);
});
});
describe('setNestedValue', () => {
it('should set a top-level value', () => {
const obj: Record<string, unknown> = {};
setNestedValue(obj, 'foo', 'bar');
expect(obj.foo).toBe('bar');
});
it('should set a nested value', () => {
const obj: Record<string, unknown> = {};
setNestedValue(obj, 'a.b.c', 'deep');
expect((obj.a as Record<string, unknown>).b).toEqual({ c: 'deep' });
});
it('should create intermediate objects', () => {
const obj: Record<string, unknown> = {};
setNestedValue(obj, 'x.y.z', 'value');
expect(obj).toEqual({ x: { y: { z: 'value' } } });
});
it('should overwrite existing values', () => {
const obj: Record<string, unknown> = { a: 'old' };
setNestedValue(obj, 'a', 'new');
expect(obj.a).toBe('new');
});
it('should overwrite primitive with object when setting nested path', () => {
const obj: Record<string, unknown> = { a: 'string' };
setNestedValue(obj, 'a.b', 'value');
expect(obj.a).toEqual({ b: 'value' });
});
it('should preserve other keys when setting nested value', () => {
const obj: Record<string, unknown> = { a: { existing: 'keep' } };
setNestedValue(obj, 'a.new', 'added');
expect(obj.a).toEqual({ existing: 'keep', new: 'added' });
});
});
describe('deleteNestedValue', () => {
it('should delete a top-level key', () => {
const obj: Record<string, unknown> = { foo: 'bar', baz: 'qux' };
const result = deleteNestedValue(obj, 'foo');
expect(result).toBe(true);
expect(obj).toEqual({ baz: 'qux' });
});
it('should delete a nested key', () => {
const obj: Record<string, unknown> = { a: { b: 'value', c: 'keep' } };
const result = deleteNestedValue(obj, 'a.b');
expect(result).toBe(true);
expect(obj.a).toEqual({ c: 'keep' });
});
it('should return false for non-existent key', () => {
const obj: Record<string, unknown> = { foo: 'bar' };
const result = deleteNestedValue(obj, 'baz');
expect(result).toBe(false);
});
it('should return false for non-existent nested path', () => {
const obj: Record<string, unknown> = { a: { b: 'value' } };
const result = deleteNestedValue(obj, 'a.c');
expect(result).toBe(false);
});
it('should return false when intermediate path does not exist', () => {
const obj: Record<string, unknown> = { a: 'string' };
const result = deleteNestedValue(obj, 'a.b.c');
expect(result).toBe(false);
});
});
describe('coerceValue', () => {
it('should coerce "true" to boolean true', () => {
expect(coerceValue('true')).toBe(true);
});
it('should coerce "false" to boolean false', () => {
expect(coerceValue('false')).toBe(false);
});
it('should coerce integer string to number', () => {
expect(coerceValue('42')).toBe(42);
});
it('should coerce float string to number', () => {
expect(coerceValue('3.14')).toBe(3.14);
});
it('should coerce negative number string to number', () => {
expect(coerceValue('-10')).toBe(-10);
});
it('should keep regular strings as strings', () => {
expect(coerceValue('hello')).toBe('hello');
});
it('should keep strings that start with numbers but are not numbers', () => {
expect(coerceValue('123abc')).toBe('123abc');
});
it('should keep empty string as string', () => {
expect(coerceValue('')).toBe('');
});
it('should keep whitespace-only string as string', () => {
expect(coerceValue(' ')).toBe(' ');
});
it('should force string when forceString is true', () => {
expect(coerceValue('true', true)).toBe('true');
expect(coerceValue('42', true)).toBe('42');
expect(coerceValue('hello', true)).toBe('hello');
});
it('should not coerce Infinity to number (not finite)', () => {
// Infinity is not a useful config value, so we keep it as string
expect(coerceValue('Infinity')).toBe('Infinity');
});
it('should handle scientific notation', () => {
expect(coerceValue('1e10')).toBe(1e10);
});
});
describe('formatValueYaml', () => {
it('should format null as "null"', () => {
expect(formatValueYaml(null)).toBe('null');
});
it('should format undefined as "null"', () => {
expect(formatValueYaml(undefined)).toBe('null');
});
it('should format boolean as string', () => {
expect(formatValueYaml(true)).toBe('true');
expect(formatValueYaml(false)).toBe('false');
});
it('should format number as string', () => {
expect(formatValueYaml(42)).toBe('42');
expect(formatValueYaml(3.14)).toBe('3.14');
});
it('should format string as-is', () => {
expect(formatValueYaml('hello')).toBe('hello');
});
it('should format empty array as "[]"', () => {
expect(formatValueYaml([])).toBe('[]');
});
it('should format empty object as "{}"', () => {
expect(formatValueYaml({})).toBe('{}');
});
it('should format object with key-value pairs', () => {
const result = formatValueYaml({ foo: 'bar' });
expect(result).toBe('foo: bar');
});
it('should format nested objects with indentation', () => {
const result = formatValueYaml({ a: { b: 'value' } });
expect(result).toContain('a:');
expect(result).toContain('b: value');
});
});
describe('validateConfig', () => {
it('should accept valid config with featureFlags', () => {
const result = validateConfig({ featureFlags: { test: true } });
expect(result.success).toBe(true);
});
it('should accept empty featureFlags', () => {
const result = validateConfig({ featureFlags: {} });
expect(result.success).toBe(true);
});
it('should accept config without featureFlags (uses default)', () => {
const result = validateConfig({});
expect(result.success).toBe(true);
});
it('should accept unknown fields (passthrough)', () => {
const result = validateConfig({ featureFlags: {}, unknownField: 'value' });
expect(result.success).toBe(true);
});
it('should accept unknown fields with various types', () => {
const result = validateConfig({
featureFlags: {},
futureStringField: 'value',
futureNumberField: 123,
futureObjectField: { nested: 'data' },
});
expect(result.success).toBe(true);
});
it('should reject non-boolean values in featureFlags', () => {
const result = validateConfig({ featureFlags: { test: 'string' } });
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
it('should include path in error message for invalid featureFlags', () => {
const result = validateConfig({ featureFlags: { someFlag: 'notABoolean' } });
expect(result.success).toBe(false);
expect(result.error).toContain('featureFlags');
});
it('should reject non-object featureFlags', () => {
const result = validateConfig({ featureFlags: 'string' });
expect(result.success).toBe(false);
});
it('should reject number values in featureFlags', () => {
const result = validateConfig({ featureFlags: { flag: 123 } });
expect(result.success).toBe(false);
});
});
describe('config set simulation', () => {
// These tests simulate the full config set flow: coerce value → set nested → validate
it('should accept setting unknown top-level key (forward compatibility)', () => {
const config: Record<string, unknown> = { featureFlags: {} };
const value = coerceValue('123');
setNestedValue(config, 'someFutureKey', value);
const result = validateConfig(config);
expect(result.success).toBe(true);
expect(config.someFutureKey).toBe(123);
});
it('should reject setting non-boolean to featureFlags', () => {
const config: Record<string, unknown> = { featureFlags: {} };
const value = coerceValue('notABoolean'); // stays as string
setNestedValue(config, 'featureFlags.someFlag', value);
const result = validateConfig(config);
expect(result.success).toBe(false);
expect(result.error).toContain('featureFlags');
});
it('should accept setting boolean to featureFlags', () => {
const config: Record<string, unknown> = { featureFlags: {} };
const value = coerceValue('true'); // coerces to boolean
setNestedValue(config, 'featureFlags.newFlag', value);
const result = validateConfig(config);
expect(result.success).toBe(true);
expect((config.featureFlags as Record<string, unknown>).newFlag).toBe(true);
});
it('should create featureFlags object when setting nested flag', () => {
const config: Record<string, unknown> = {};
const value = coerceValue('false');
setNestedValue(config, 'featureFlags.experimental', value);
const result = validateConfig(config);
expect(result.success).toBe(true);
expect((config.featureFlags as Record<string, unknown>).experimental).toBe(false);
});
});
describe('GlobalConfigSchema', () => {
it('should parse valid config', () => {
const result = GlobalConfigSchema.safeParse({ featureFlags: { test: true } });
expect(result.success).toBe(true);
});
it('should provide defaults for missing featureFlags', () => {
const result = GlobalConfigSchema.parse({});
expect(result.featureFlags).toEqual({});
});
});
describe('DEFAULT_CONFIG', () => {
it('should have empty featureFlags', () => {
expect(DEFAULT_CONFIG.featureFlags).toEqual({});
});
});
});