235 lines
9.0 KiB
TypeScript
235 lines
9.0 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { Command } from 'commander';
|
|
|
|
import {
|
|
registerUninstallCommand,
|
|
reverseRuntimeAssets,
|
|
reverseNpmrc,
|
|
removeFramework,
|
|
removeCli,
|
|
} from './uninstall.js';
|
|
import { writeManifest, createManifest } from '../runtime/install-manifest.js';
|
|
|
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
let tmpDir: string;
|
|
let mosaicHome: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-uninstall-test-'));
|
|
mosaicHome = join(tmpDir, 'mosaic');
|
|
mkdirSync(mosaicHome, { recursive: true });
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ─── command registration ────────────────────────────────────────────────────
|
|
|
|
describe('registerUninstallCommand', () => {
|
|
it('registers an "uninstall" command on the program', () => {
|
|
const program = new Command();
|
|
program.exitOverride();
|
|
registerUninstallCommand(program);
|
|
const names = program.commands.map((c) => c.name());
|
|
expect(names).toContain('uninstall');
|
|
});
|
|
|
|
it('registers the expected options', () => {
|
|
const program = new Command();
|
|
program.exitOverride();
|
|
registerUninstallCommand(program);
|
|
const cmd = program.commands.find((c) => c.name() === 'uninstall')!;
|
|
const optNames = cmd.options.map((o) => o.long);
|
|
expect(optNames).toContain('--framework');
|
|
expect(optNames).toContain('--cli');
|
|
expect(optNames).toContain('--gateway');
|
|
expect(optNames).toContain('--all');
|
|
expect(optNames).toContain('--keep-data');
|
|
expect(optNames).toContain('--dry-run');
|
|
});
|
|
});
|
|
|
|
// ─── reverseNpmrc ─────────────────────────────────────────────────────────────
|
|
|
|
describe('reverseNpmrc', () => {
|
|
it('does nothing when .npmrc does not exist (heuristic mode, no manifest)', () => {
|
|
// Should not throw; mosaicHome has no manifest and home has no .npmrc
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
expect(() => reverseNpmrc(mosaicHome, true)).not.toThrow();
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('dry-run mode logs removal without mutating', () => {
|
|
// Write a manifest with a known npmrc line
|
|
writeManifest(
|
|
mosaicHome,
|
|
createManifest('0.0.24', 2, {
|
|
npmrcLines: [
|
|
'@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/',
|
|
],
|
|
}),
|
|
);
|
|
// reverseNpmrc reads ~/.npmrc from actual homedir; dry-run won't touch anything
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
expect(() => reverseNpmrc(mosaicHome, true)).not.toThrow();
|
|
logSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// ─── removeFramework ──────────────────────────────────────────────────────────
|
|
|
|
describe('removeFramework', () => {
|
|
it('removes the entire directory when --keep-data is false', () => {
|
|
writeFileSync(join(mosaicHome, 'AGENTS.md'), '# agents', 'utf8');
|
|
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
removeFramework(mosaicHome, false, false);
|
|
logSpy.mockRestore();
|
|
|
|
expect(existsSync(mosaicHome)).toBe(false);
|
|
});
|
|
|
|
it('preserves SOUL.md and memory/ when --keep-data is true', () => {
|
|
writeFileSync(join(mosaicHome, 'AGENTS.md'), '# agents', 'utf8');
|
|
writeFileSync(join(mosaicHome, 'SOUL.md'), '# soul', 'utf8');
|
|
mkdirSync(join(mosaicHome, 'memory'), { recursive: true });
|
|
writeFileSync(join(mosaicHome, 'memory', 'note.md'), 'note', 'utf8');
|
|
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
removeFramework(mosaicHome, true, false);
|
|
logSpy.mockRestore();
|
|
|
|
expect(existsSync(join(mosaicHome, 'SOUL.md'))).toBe(true);
|
|
expect(existsSync(join(mosaicHome, 'memory'))).toBe(true);
|
|
expect(existsSync(join(mosaicHome, 'AGENTS.md'))).toBe(false);
|
|
});
|
|
|
|
it('preserves USER.md and TOOLS.md when --keep-data is true', () => {
|
|
writeFileSync(join(mosaicHome, 'AGENTS.md'), '# agents', 'utf8');
|
|
writeFileSync(join(mosaicHome, 'USER.md'), '# user', 'utf8');
|
|
writeFileSync(join(mosaicHome, 'TOOLS.md'), '# tools', 'utf8');
|
|
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
removeFramework(mosaicHome, true, false);
|
|
logSpy.mockRestore();
|
|
|
|
expect(existsSync(join(mosaicHome, 'USER.md'))).toBe(true);
|
|
expect(existsSync(join(mosaicHome, 'TOOLS.md'))).toBe(true);
|
|
});
|
|
|
|
it('dry-run logs but does not remove', () => {
|
|
writeFileSync(join(mosaicHome, 'AGENTS.md'), '# agents', 'utf8');
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
|
|
removeFramework(mosaicHome, false, true);
|
|
logSpy.mockRestore();
|
|
|
|
expect(existsSync(mosaicHome)).toBe(true);
|
|
});
|
|
|
|
it('handles missing mosaicHome gracefully', () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
expect(() => removeFramework('/nonexistent/path', false, false)).not.toThrow();
|
|
logSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// ─── reverseRuntimeAssets ─────────────────────────────────────────────────────
|
|
|
|
describe('reverseRuntimeAssets', () => {
|
|
it('dry-run does not throw in heuristic mode (no manifest)', () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
|
|
expect(() => reverseRuntimeAssets(mosaicHome, true)).not.toThrow();
|
|
|
|
logSpy.mockRestore();
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('restores backup when present (with manifest)', () => {
|
|
// Create a fake dest and backup inside tmpDir
|
|
const claudeDir = join(tmpDir, 'dot-claude');
|
|
mkdirSync(claudeDir, { recursive: true });
|
|
const dest = join(claudeDir, 'settings.json');
|
|
const backup = join(claudeDir, 'settings.json.mosaic-bak-20260405120000');
|
|
writeFileSync(dest, '{"current": true}', 'utf8');
|
|
writeFileSync(backup, '{"original": true}', 'utf8');
|
|
|
|
// Write a manifest pointing to these exact paths
|
|
writeManifest(
|
|
mosaicHome,
|
|
createManifest('0.0.24', 2, {
|
|
runtimeAssetCopies: [{ source: '/src/settings.json', dest, backup }],
|
|
}),
|
|
);
|
|
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
reverseRuntimeAssets(mosaicHome, false);
|
|
logSpy.mockRestore();
|
|
|
|
// Backup removed, dest has original content
|
|
expect(existsSync(backup)).toBe(false);
|
|
expect(readFileSync(dest, 'utf8')).toBe('{"original": true}');
|
|
});
|
|
|
|
it('removes managed copy when no backup present (with manifest)', () => {
|
|
const claudeDir = join(tmpDir, 'dot-claude2');
|
|
mkdirSync(claudeDir, { recursive: true });
|
|
const dest = join(claudeDir, 'CLAUDE.md');
|
|
writeFileSync(dest, '# managed', 'utf8');
|
|
|
|
writeManifest(
|
|
mosaicHome,
|
|
createManifest('0.0.24', 2, {
|
|
runtimeAssetCopies: [{ source: '/src/CLAUDE.md', dest }],
|
|
}),
|
|
);
|
|
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
reverseRuntimeAssets(mosaicHome, false);
|
|
logSpy.mockRestore();
|
|
|
|
expect(existsSync(dest)).toBe(false);
|
|
});
|
|
|
|
it('dry-run with manifest logs but does not remove', () => {
|
|
const claudeDir = join(tmpDir, 'dot-claude3');
|
|
mkdirSync(claudeDir, { recursive: true });
|
|
const dest = join(claudeDir, 'hooks-config.json');
|
|
writeFileSync(dest, '{}', 'utf8');
|
|
|
|
writeManifest(
|
|
mosaicHome,
|
|
createManifest('0.0.24', 2, {
|
|
runtimeAssetCopies: [{ source: '/src/hooks-config.json', dest }],
|
|
}),
|
|
);
|
|
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
reverseRuntimeAssets(mosaicHome, true);
|
|
logSpy.mockRestore();
|
|
|
|
// File should still exist in dry-run mode
|
|
expect(existsSync(dest)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── removeCli ────────────────────────────────────────────────────────────────
|
|
|
|
describe('removeCli', () => {
|
|
it('dry-run logs the npm command without running it', () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
removeCli(true);
|
|
const output = logSpy.mock.calls.map((c) => c[0] as string).join('\n');
|
|
expect(output).toContain('npm uninstall -g @mosaicstack/mosaic');
|
|
logSpy.mockRestore();
|
|
});
|
|
});
|