/** * Smoke tests for the migrate-config CLI. We invoke the bash wrapper as a * subprocess so the test exercises the same code path operators will use. * * The bash wrapper auto-picks dist/scripts/migrate-config.js if it exists, * else falls back to tsx — both are tested transparently because we don't * care which runtime ran, only that the output is correct. */ import { afterEach, describe, expect, it } from 'vitest'; import { transformCamel } from './migrate-config.js'; import { spawnSync } from 'child_process'; import { copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = join(__dirname, '..', '..'); // src/scripts → repo root const SCRIPT = join(REPO_ROOT, 'scripts', 'migrate-config.sh'); const FIXTURES = join(__dirname, '..', '__fixtures__', 'config-migration'); function runCli(args: string[]): { stdout: string; stderr: string; status: number } { const r = spawnSync('bash', [SCRIPT, ...args], { encoding: 'utf-8', cwd: REPO_ROOT }); return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 }; } describe('migrate-config CLI', () => { let tempDir = ''; afterEach(() => { if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; } }); it('--help exits 0 and prints usage', () => { const r = runCli(['--help']); expect(r.status).toBe(0); expect(r.stdout).toContain('Usage: migrate-config'); }); it('unknown flag exits 3', () => { const r = runCli(['--nope']); expect(r.status).toBe(3); expect(r.stderr).toMatch(/unknown flag/); }); it('missing config file exits 1', () => { tempDir = mkdtempSync(join(tmpdir(), 'migrate-config-')); const r = runCli(['--config', join(tempDir, 'nope.yaml')]); expect(r.status).toBe(1); expect(r.stderr).toMatch(/config file not found/); }); it('dry-run on v1 fixture prints v2 YAML to stdout and does not modify the file', () => { tempDir = mkdtempSync(join(tmpdir(), 'migrate-config-')); const target = join(tempDir, 'config.yaml'); copyFileSync(join(FIXTURES, 'v1-single-ollama.yaml'), target); const before = readFileSync(target, 'utf-8'); const r = runCli(['--dry-run', '--config', target]); expect(r.status).toBe(0); expect(r.stdout).toMatch(/config_version: 2/); expect(r.stdout).toMatch(/connection_type: direct/); // Dry run does NOT touch the source file. expect(readFileSync(target, 'utf-8')).toBe(before); expect(readdirSync(tempDir).filter(f => f.includes('.bak-'))).toHaveLength(0); }); it('in-place rewrite produces v2 file and a timestamped backup', () => { tempDir = mkdtempSync(join(tmpdir(), 'migrate-config-')); const target = join(tempDir, 'config.yaml'); copyFileSync(join(FIXTURES, 'v1-single-ollama.yaml'), target); const r = runCli(['--config', target]); expect(r.status).toBe(0); const migrated = readFileSync(target, 'utf-8'); expect(migrated).toMatch(/^config_version: 2/m); expect(migrated).toMatch(/storage:/); // Exactly one backup file in the same directory. const backups = readdirSync(tempDir).filter(f => f.startsWith('config.yaml.bak-')); expect(backups).toHaveLength(1); // Backup matches the original input. const original = readFileSync(join(FIXTURES, 'v1-single-ollama.yaml'), 'utf-8'); expect(readFileSync(join(tempDir, backups[0]!), 'utf-8')).toBe(original); }); it('already-v2 file → no-op + exit 0', () => { tempDir = mkdtempSync(join(tmpdir(), 'migrate-config-')); const target = join(tempDir, 'config.yaml'); writeFileSync( target, [ 'config_version: 2', 'llm:', ' workers:', ' - id: w1', ' connection_type: direct', ' endpoint: http://localhost:11434/v1', ' model: qwen3:32b', ' roles: [auto]', ' max_concurrency: 1', ' enabled: true', '', ].join('\n'), ); const before = readFileSync(target, 'utf-8'); const r = runCli(['--config', target]); expect(r.status).toBe(0); expect(r.stdout).toMatch(/already up to date/); // No rewrite (no backup either). expect(readFileSync(target, 'utf-8')).toBe(before); expect(readdirSync(tempDir).filter(f => f.includes('.bak-'))).toHaveLength(0); }); it('config_version: 99 exits 2 with the typo-guard message', () => { tempDir = mkdtempSync(join(tmpdir(), 'migrate-config-')); const target = join(tempDir, 'config.yaml'); writeFileSync(target, 'config_version: 99\nprovider:\n model: x\n'); const r = runCli(['--config', target]); expect(r.status).toBe(2); expect(r.stderr).toMatch(/config_version=99/); }); }); describe('transformCamel', () => { it('extra_body の中身のキーは camelCase 変換せず素通しする(src/config.ts transformKeys と同じ不変条件)', () => { const input = { provider: { workers: [ { id: 'w1', extra_body: { reasoning_effort: 'max', chat_template_kwargs: { enable_thinking: true } } }, ], }, }; const out = transformCamel(input) as any; expect(out.provider.workers[0].extraBody).toEqual({ reasoning_effort: 'max', chat_template_kwargs: { enable_thinking: true }, }); }); });