import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { FileConfigAdapter, DEFAULT_SEED_FILES } from './file-adapter.js'; /** * Regression tests for the `FileConfigAdapter.syncFramework` seed behavior. * * Background: the bash installer (`framework/install.sh`) and this TS wizard * path both seed framework-contract files from `framework/defaults/` into the * user's mosaic home on first install. Before this fix: * * - The bash installer only seeded `AGENTS.md` and `STANDARDS.md`, leaving * `TOOLS.md` missing despite it being listed as mandatory in the * AGENTS.md load order (position 5). * - The TS wizard iterated every file in `defaults/` and copied it to the * mosaic home root — including `defaults/SOUL.md` (hardcoded "Jarvis"), * `defaults/USER.md` (placeholder), and internal framework files like * `README.md` and `AUDIT-*.md`. That clobbered the identity flow on * fresh installs and leaked framework-internal clutter into the user's * home directory. * * This suite pins the whitelist and the preservation semantics so both * regressions stay fixed. */ function makeFixture(): { sourceDir: string; mosaicHome: string; defaultsDir: string } { const root = mkdtempSync(join(tmpdir(), 'mosaic-file-adapter-')); const sourceDir = join(root, 'source'); const mosaicHome = join(root, 'mosaic-home'); const defaultsDir = join(sourceDir, 'defaults'); mkdirSync(defaultsDir, { recursive: true }); mkdirSync(mosaicHome, { recursive: true }); // Framework-contract defaults we expect the wizard to seed. writeFileSync(join(defaultsDir, 'CONSTITUTION.md'), '# CONSTITUTION default\n'); writeFileSync(join(defaultsDir, 'AGENTS.md'), '# AGENTS default\n'); writeFileSync(join(defaultsDir, 'STANDARDS.md'), '# STANDARDS default\n'); writeFileSync(join(defaultsDir, 'TOOLS.md'), '# TOOLS default\n'); // Non-contract files we must NOT seed on first install. writeFileSync(join(defaultsDir, 'SOUL.md'), '# SOUL default (should not be seeded)\n'); writeFileSync(join(defaultsDir, 'USER.md'), '# USER default (should not be seeded)\n'); writeFileSync(join(defaultsDir, 'README.md'), '# README (framework-internal)\n'); writeFileSync( join(defaultsDir, 'AUDIT-2026-02-17-framework-consistency.md'), '# Audit snapshot\n', ); return { sourceDir, mosaicHome, defaultsDir }; } describe('FileConfigAdapter.syncFramework — defaults seeding', () => { let fixture: ReturnType; beforeEach(() => { fixture = makeFixture(); }); afterEach(() => { rmSync(join(fixture.sourceDir, '..'), { recursive: true, force: true }); }); it('seeds the four framework-contract files on a fresh mosaic home', async () => { const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir); await adapter.syncFramework('fresh'); for (const name of DEFAULT_SEED_FILES) { expect(existsSync(join(fixture.mosaicHome, name))).toBe(true); } expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toContain( '# TOOLS default', ); }); it('does NOT seed SOUL.md or USER.md from defaults/ (wizard stages own those)', async () => { const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir); await adapter.syncFramework('fresh'); // SOUL.md and USER.md live in defaults/ for historical reasons, but they // are template-rendered per-user by the wizard stages. Seeding them here // would clobber the identity flow and leak placeholder content. expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false); expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false); }); it('does NOT seed README.md or AUDIT-*.md from defaults/', async () => { const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir); await adapter.syncFramework('fresh'); expect(existsSync(join(fixture.mosaicHome, 'README.md'))).toBe(false); expect(existsSync(join(fixture.mosaicHome, 'AUDIT-2026-02-17-framework-consistency.md'))).toBe( false, ); }); it('overwrites framework-owned files (backup-once) but preserves user-seeded files', async () => { // Plant a root-level AGENTS.md in sourceDir so syncDirectory's preserve is exercised. writeFileSync(join(fixture.sourceDir, 'AGENTS.md'), '# shipped AGENTS from source root\n'); writeFileSync(join(fixture.mosaicHome, 'TOOLS.md'), '# user-customized TOOLS\n'); writeFileSync(join(fixture.mosaicHome, 'AGENTS.md'), '# user-customized AGENTS\n'); const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir); await adapter.syncFramework('keep'); // User-seeded TOOLS.md is preserved. expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toBe( '# user-customized TOOLS\n', ); // Framework-owned AGENTS.md is overwritten from defaults/ ... expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe('# AGENTS default\n'); // ... and the user's prior copy is backed up exactly once. expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md.pre-constitution.bak'), 'utf-8')).toBe( '# user-customized AGENTS\n', ); // Framework-owned STANDARDS.md (absent) gets installed. expect(readFileSync(join(fixture.mosaicHome, 'STANDARDS.md'), 'utf-8')).toContain( '# STANDARDS default', ); }); it('backs up a divergent framework-owned file only once (idempotent across re-sync)', async () => { writeFileSync(join(fixture.mosaicHome, 'AGENTS.md'), '# user-customized AGENTS\n'); const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir); await adapter.syncFramework('keep'); // 1st: backup created, AGENTS overwritten await adapter.syncFramework('keep'); // 2nd: AGENTS already == default, no new backup expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md.pre-constitution.bak'), 'utf-8')).toBe( '# user-customized AGENTS\n', ); }); it('preserves SOUL.md and credentials through a framework-owned overwrite', async () => { writeFileSync(join(fixture.mosaicHome, 'SOUL.md'), '# my persona\n'); writeFileSync(join(fixture.mosaicHome, 'AGENTS.md'), '# user-customized AGENTS\n'); mkdirSync(join(fixture.mosaicHome, 'credentials'), { recursive: true }); writeFileSync(join(fixture.mosaicHome, 'credentials', 'c.json'), 'token\n'); const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir); await adapter.syncFramework('keep'); expect(readFileSync(join(fixture.mosaicHome, 'SOUL.md'), 'utf-8')).toBe('# my persona\n'); expect(readFileSync(join(fixture.mosaicHome, 'credentials', 'c.json'), 'utf-8')).toBe( 'token\n', ); expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe('# AGENTS default\n'); }); it('is a no-op for seeding when defaults/ dir does not exist', async () => { rmSync(fixture.defaultsDir, { recursive: true }); const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir); await expect(adapter.syncFramework('fresh')).resolves.toBeUndefined(); expect(existsSync(join(fixture.mosaicHome, 'TOOLS.md'))).toBe(false); }); });