import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import type { ModInstallation, Mod } from '../types/mod';
// Use vi.hoisted to ensure xmlParseMock is available in the mock
const { xmlParseMock } = vi.hoisted(() => {
return {
xmlParseMock: vi.fn()
};
});
// Mock fs/promises FIRST - before any imports that might use it
vi.mock('fs/promises', () => {
const readdir = vi.fn();
const readFile = vi.fn();
const rm = vi.fn();
return {
default: {
readdir,
readFile,
rm,
},
readdir,
readFile,
rm,
};
});
// Mock fast-xml-parser
vi.mock('fast-xml-parser', () => {
return {
XMLParser: vi.fn(() => ({
parse: xmlParseMock
}))
};
});
// Now import after mocks are set up
import { ModManager } from './mod-manager';
import { ConfigManager } from './config-manager';
import { RepositoryManager } from './repository-manager';
import fs from 'fs/promises';
describe('ModManager', () => {
let modManager: ModManager;
let mockConfig: ConfigManager;
let mockRepository: RepositoryManager;
let testMods: Mod[];
beforeEach(() => {
// Use resetAllMocks instead of clearAllMocks to preserve mock implementations
vi.resetAllMocks();
// Set up default fs mocks
vi.mocked(fs.readdir).mockResolvedValue([]);
vi.mocked(fs.readFile).mockRejectedValue(new Error('Not mocked'));
vi.mocked(fs.rm).mockResolvedValue(undefined);
// Create test mod data
testMods = [
{
id: 'test-mod-1',
name: 'test-mod-1',
label: 'Test Mod 1',
remote: 'https://github.com/test/test-mod-1.git',
deprecated: false
},
{
id: 'test-mod-2',
name: 'test-mod-2',
label: 'Test Mod 2',
remote: 'https://github.com/test/test-mod-2.git',
deprecated: false
},
{
id: 'deprecated-mod',
name: 'deprecated-mod',
label: 'Deprecated Mod',
remote: 'https://github.com/test/deprecated.git',
deprecated: true,
remark: 'This mod is deprecated'
}
];
// Mock ConfigManager
mockConfig = {
getInstallationDir: vi.fn().mockReturnValue('/test/mods'),
setInstallationDir: vi.fn(),
getInstalledMods: vi.fn().mockReturnValue([]),
setInstalledMods: vi.fn(),
addOrUpdateInstalledMod: vi.fn(),
removeInstalledMod: vi.fn(),
findInstalledModByRemote: vi.fn(),
isModInstalled: vi.fn().mockReturnValue(false),
getPreferences: vi.fn().mockReturnValue({
autoUpdate: false,
colorOutput: true,
verbosity: 'normal',
parallelOperations: 3
}),
updatePreferences: vi.fn(),
getLastMasterlistUpdate: vi.fn(),
setLastMasterlistUpdate: vi.fn(),
clear: vi.fn(),
getAll: vi.fn()
} as any;
// Mock RepositoryManager
mockRepository = {
cloneRepository: vi.fn().mockResolvedValue(undefined),
updateRepository: vi.fn().mockResolvedValue({
success: true,
hasChanges: false
}),
getRepositoryStatus: vi.fn().mockResolvedValue({
isValidRepo: true,
currentCommit: 'abc123',
remoteUrl: 'https://github.com/test/repo.git'
}),
getRemoteUrl: vi.fn().mockResolvedValue('https://github.com/test/repo.git'),
getRecentCommits: vi.fn().mockResolvedValue([]),
getCurrentCommit: vi.fn().mockResolvedValue('abc123'),
setRemoteUrl: vi.fn().mockResolvedValue(undefined),
hasUncommittedChanges: vi.fn().mockResolvedValue(false)
} as any;
// Create ModManager instance
modManager = new ModManager(mockConfig, mockRepository, testMods);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('installMod', () => {
it('should install a mod successfully', async () => {
const modName = 'test-mod-1';
const installDir = '/test/mods';
const result = await modManager.installMod(modName, installDir);
expect(result).toMatchObject({
modId: 'test-mod-1',
name: modName,
directory: '/test/mods/test-mod-1',
remote: 'https://github.com/test/test-mod-1.git'
});
expect(mockRepository.cloneRepository).toHaveBeenCalledWith(
'https://github.com/test/test-mod-1.git',
'/test/mods/test-mod-1'
);
expect(mockConfig.addOrUpdateInstalledMod).toHaveBeenCalled();
});
it('should throw error for unknown mod', async () => {
await expect(modManager.installMod('unknown-mod', '/test/mods'))
.rejects
.toThrow("Mod 'unknown-mod' is not a known mod and cannot be installed");
});
it('should throw error for already installed mod', async () => {
mockConfig.isModInstalled = vi.fn().mockReturnValue(true);
await expect(modManager.installMod('test-mod-1', '/test/mods'))
.rejects
.toThrow("Mod 'test-mod-1' is already installed");
});
it('should extract mod versions from About.xml', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(`
1.4
1.5
`);
// Set up XMLParser mock to return parsed data
xmlParseMock.mockReturnValueOnce({
ModMetaData: {
supportedVersions: {
li: ['1.4', '1.5']
}
}
});
const result = await modManager.installMod('test-mod-1', '/test/mods');
expect(result.supportedVersions).toEqual(['1.4', '1.5']);
});
it('should handle version extraction failure gracefully', async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error('File not found'));
const result = await modManager.installMod('test-mod-1', '/test/mods');
// Should still install even if version extraction fails
expect(result).toBeDefined();
expect(result.supportedVersions).toEqual([]);
});
});
describe('updateMod', () => {
const testInstallation: ModInstallation = {
modId: 'test-mod',
name: 'test-mod',
directory: '/test/mods/test-mod',
remote: 'https://github.com/test/test-mod.git',
installedAt: new Date(),
lastUpdated: new Date()
};
it('should update mod with changes', async () => {
mockRepository.getCurrentCommit = vi.fn()
.mockResolvedValueOnce('abc123')
.mockResolvedValueOnce('def456');
mockRepository.updateRepository = vi.fn().mockResolvedValue({
success: true,
hasChanges: true
});
const result = await modManager.updateMod(testInstallation);
expect(result).toMatchObject({
modId: 'test-mod',
success: true,
hasChanges: true,
previousVersion: 'abc123',
newVersion: 'def456'
});
expect(mockConfig.addOrUpdateInstalledMod).toHaveBeenCalled();
});
it('should update mod without changes', async () => {
mockRepository.getCurrentCommit = vi.fn().mockResolvedValue('abc123');
mockRepository.updateRepository = vi.fn().mockResolvedValue({
success: true,
hasChanges: false
});
const result = await modManager.updateMod(testInstallation);
expect(result).toMatchObject({
success: true,
hasChanges: false
});
});
it('should handle update failure', async () => {
const error = new Error('Network error');
mockRepository.updateRepository = vi.fn().mockRejectedValue(error);
const result = await modManager.updateMod(testInstallation);
expect(result).toMatchObject({
modId: 'test-mod',
success: false,
hasChanges: false,
error
});
});
});
describe('updateAllMods', () => {
it('should update all installed mods in parallel', async () => {
const installedMods: ModInstallation[] = [
{
modId: 'mod1',
name: 'mod1',
directory: '/test/mods/mod1',
remote: 'https://github.com/test/mod1.git',
installedAt: new Date(),
lastUpdated: new Date()
},
{
modId: 'mod2',
name: 'mod2',
directory: '/test/mods/mod2',
remote: 'https://github.com/test/mod2.git',
installedAt: new Date(),
lastUpdated: new Date()
}
];
mockConfig.getInstalledMods = vi.fn().mockReturnValue(installedMods);
const results = await modManager.updateAllMods();
expect(results).toHaveLength(2);
expect(mockRepository.updateRepository).toHaveBeenCalledTimes(2);
});
it('should handle empty installed mods list', async () => {
mockConfig.getInstalledMods = vi.fn().mockReturnValue([]);
const results = await modManager.updateAllMods();
expect(results).toEqual([]);
});
});
describe('uninstallMod', () => {
const testInstallation: ModInstallation = {
modId: 'test-mod-1',
name: 'test-mod-1',
directory: '/test/mods/test-mod-1',
remote: 'https://github.com/test/test-mod-1.git',
installedAt: new Date(),
lastUpdated: new Date()
};
it('should uninstall mod successfully', async () => {
mockConfig.findInstalledModByRemote = vi.fn().mockReturnValue(testInstallation);
vi.mocked(fs.rm).mockResolvedValue(undefined);
await modManager.uninstallMod('test-mod-1');
expect(fs.rm).toHaveBeenCalledWith('/test/mods/test-mod-1/.git', {
recursive: true,
force: true
});
expect(fs.rm).toHaveBeenCalledWith('/test/mods/test-mod-1', {
recursive: true,
force: true
});
expect(mockConfig.removeInstalledMod).toHaveBeenCalledWith('test-mod-1');
});
it('should throw error for unknown mod', async () => {
await expect(modManager.uninstallMod('unknown-mod'))
.rejects
.toThrow("Mod 'unknown-mod' is not a known mod and cannot be uninstalled");
});
it('should throw error for not installed mod', async () => {
mockConfig.findInstalledModByRemote = vi.fn().mockReturnValue(undefined);
await expect(modManager.uninstallMod('test-mod-1'))
.rejects
.toThrow("Mod 'test-mod-1' is not installed");
});
it('should provide manual removal instructions on failure', async () => {
mockConfig.findInstalledModByRemote = vi.fn().mockReturnValue(testInstallation);
vi.mocked(fs.rm).mockRejectedValue(new Error('Permission denied'));
await expect(modManager.uninstallMod('test-mod-1'))
.rejects
.toThrow(/Failed to remove mod directory.*rm -rf/s);
});
});
describe('scanDirectory', () => {
it('should scan directory and find mods', async () => {
vi.mocked(fs.readdir).mockResolvedValueOnce(['test-mod-1', 'test-mod-2', '.hidden'] as any);
mockRepository.getRepositoryStatus = vi.fn()
.mockResolvedValueOnce({
isValidRepo: true,
currentCommit: 'abc123',
remoteUrl: 'https://github.com/test/test-mod-1.git'
})
.mockResolvedValueOnce({
isValidRepo: true,
currentCommit: 'def456',
remoteUrl: 'https://github.com/test/test-mod-2.git'
});
mockConfig.isModInstalled = vi.fn()
.mockReturnValueOnce(false) // test-mod-1 is new
.mockReturnValueOnce(true); // test-mod-2 is already installed
const result = await modManager.scanDirectory('/test/mods');
expect(result.found).toHaveLength(2);
expect(result.found[0]).toMatchObject({
name: 'test-mod-1',
remote: 'https://github.com/test/test-mod-1.git',
isNew: true
});
expect(result.found[1]).toMatchObject({
name: 'test-mod-2',
isNew: false
});
});
it('should identify unknown directories', async () => {
vi.mocked(fs.readdir).mockResolvedValueOnce(['unknown-dir'] as any);
mockRepository.getRepositoryStatus = vi.fn().mockResolvedValue({
isValidRepo: false
});
const result = await modManager.scanDirectory('/test/mods');
expect(result.unknown).toEqual(['unknown-dir']);
});
it('should identify missing mods', async () => {
vi.mocked(fs.readdir).mockResolvedValueOnce([] as any);
const installedMod: ModInstallation = {
modId: 'missing-mod',
name: 'missing-mod',
directory: '/test/mods/missing-mod',
remote: 'https://github.com/test/missing.git',
installedAt: new Date(),
lastUpdated: new Date()
};
mockConfig.getInstalledMods = vi.fn().mockReturnValue([installedMod]);
const result = await modManager.scanDirectory('/test/mods');
expect(result.missing).toHaveLength(1);
expect(result.missing[0]).toEqual(installedMod);
});
it('should filter out hidden files and non-directories', async () => {
vi.mocked(fs.readdir).mockResolvedValueOnce([
'.git',
'.DS_Store',
'Icon\r',
'readme.txt',
'valid-mod'
] as any);
mockRepository.getRepositoryStatus = vi.fn().mockResolvedValue({
isValidRepo: true,
remoteUrl: 'https://github.com/test/test-mod-1.git'
});
await modManager.scanDirectory('/test/mods');
// Should only check 'valid-mod'
expect(mockRepository.getRepositoryStatus).toHaveBeenCalledTimes(1);
expect(mockRepository.getRepositoryStatus).toHaveBeenCalledWith('/test/mods/valid-mod');
});
it('should throw error for unreadable directory', async () => {
vi.mocked(fs.readdir).mockRejectedValue(new Error('Permission denied'));
await expect(modManager.scanDirectory('/test/mods'))
.rejects
.toThrow('Cannot read installation directory: /test/mods');
});
});
describe('findMod', () => {
it('should find mod by name', () => {
const mod = modManager.findMod('test-mod-1');
expect(mod).toEqual(testMods[0]);
});
it('should find mod by label', () => {
const mod = modManager.findMod('Test Mod 1');
expect(mod).toEqual(testMods[0]);
});
it('should return undefined for non-existent mod', () => {
const mod = modManager.findMod('non-existent');
expect(mod).toBeUndefined();
});
});
describe('searchMods', () => {
it('should search mods by term in name', () => {
const results = modManager.searchMods('test-mod');
expect(results).toHaveLength(2);
});
it('should search mods by term in label', () => {
const results = modManager.searchMods('Test Mod');
expect(results).toHaveLength(2);
});
it('should search mods by term in remark', () => {
const results = modManager.searchMods('deprecated');
expect(results).toHaveLength(1);
expect(results[0].name).toBe('deprecated-mod');
});
it('should filter out deprecated mods when specified', () => {
const results = modManager.searchMods('mod', { includeDeprecated: false });
expect(results).toHaveLength(2);
expect(results.every(m => !m.deprecated)).toBe(true);
});
it('should filter by installation status', () => {
mockConfig.isModInstalled = vi.fn()
.mockReturnValueOnce(true) // test-mod-1 is installed
.mockReturnValueOnce(false) // test-mod-2 is not
.mockReturnValueOnce(false); // deprecated-mod is not
const installed = modManager.searchMods('test', { installed: true });
expect(installed).toHaveLength(1);
expect(installed[0].name).toBe('test-mod-1');
// Reset mock for next test
mockConfig.isModInstalled = vi.fn().mockReturnValue(false);
const notInstalled = modManager.searchMods('test', { installed: false });
expect(notInstalled).toHaveLength(2);
});
it('should return empty array for no matches', () => {
const results = modManager.searchMods('xyz');
expect(results).toEqual([]);
});
it('should be case-insensitive', () => {
const results = modManager.searchMods('TEST MOD');
expect(results).toHaveLength(2);
});
});
describe('isModInstalled', () => {
it('should check by mod name', () => {
mockConfig.isModInstalled = vi.fn().mockReturnValue(true);
const result = modManager.isModInstalled('test-mod-1');
expect(result).toBe(true);
expect(mockConfig.isModInstalled).toHaveBeenCalledWith('https://github.com/test/test-mod-1.git');
});
it('should check by remote URL directly', () => {
mockConfig.isModInstalled = vi.fn().mockReturnValue(true);
const result = modManager.isModInstalled('https://some-url.git');
expect(result).toBe(true);
expect(mockConfig.isModInstalled).toHaveBeenCalledWith('https://some-url.git');
});
it('should return false for unknown mod name', () => {
const result = modManager.isModInstalled('unknown-mod');
expect(result).toBe(false);
});
});
describe('extractModVersions', () => {
it('should extract versions from About.xml', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(``);
xmlParseMock.mockReturnValueOnce({
ModMetaData: {
supportedVersions: {
li: ['1.4', '1.5']
}
}
});
const versions = await modManager.extractModVersions('/test/mod');
expect(versions).toEqual(['1.4', '1.5']);
expect(fs.readFile).toHaveBeenCalledWith('/test/mod/About/About.xml', 'utf-8');
expect(xmlParseMock).toHaveBeenCalledTimes(1);
});
it('should handle single version', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(``);
xmlParseMock.mockReturnValueOnce({
ModMetaData: {
supportedVersions: {
li: '1.5' // Single value, not array
}
}
});
const versions = await modManager.extractModVersions('/test/mod');
expect(versions).toEqual(['1.5']);
});
it('should return empty array when About.xml not found', async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error('File not found'));
const versions = await modManager.extractModVersions('/test/mod');
expect(versions).toEqual([]);
});
it('should return empty array for invalid XML', async () => {
vi.mocked(fs.readFile).mockResolvedValue('not valid xml');
xmlParseMock.mockImplementation(() => {
throw new Error('Invalid XML');
});
const versions = await modManager.extractModVersions('/test/mod');
expect(versions).toEqual([]);
// Reset mock for other tests
xmlParseMock.mockReset();
});
});
describe('getModChangelog', () => {
it('should get changelog for a mod', async () => {
const testMod: ModInstallation = {
modId: 'test',
name: 'test',
directory: '/test/mods/test',
remote: 'https://test.git',
installedAt: new Date(),
lastUpdated: new Date()
};
const commits = [
{ hash: 'abc123', message: 'Fix bug' }
];
mockRepository.getRecentCommits = vi.fn().mockResolvedValue(commits);
const result = await modManager.getModChangelog(testMod, 3);
expect(result).toEqual(commits);
expect(mockRepository.getRecentCommits).toHaveBeenCalledWith('/test/mods/test', 3);
});
});
describe('relinkMod', () => {
const directoryName = 'test-mod-1';
const installDir = '/test/mods';
const directoryPath = '/test/mods/test-mod-1';
beforeEach(() => {
mockRepository.getRepositoryStatus = vi.fn().mockResolvedValue({
isValidRepo: true,
currentCommit: 'abc123',
remoteUrl: 'https://github.com/old/old.git'
});
mockRepository.setRemoteUrl = vi.fn().mockResolvedValue(undefined);
mockRepository.hasUncommittedChanges = vi.fn().mockResolvedValue(false);
mockConfig.findInstalledModByRemote = vi.fn().mockReturnValue(undefined);
mockConfig.getInstalledMods = vi.fn().mockReturnValue([]);
});
it('should rewrite remote and write a new installation record', async () => {
const result = await modManager.relinkMod(directoryName, 'test-mod-2', installDir);
expect(mockRepository.setRemoteUrl).toHaveBeenCalledWith(
directoryPath,
'https://github.com/test/test-mod-2.git'
);
expect(mockConfig.addOrUpdateInstalledMod).toHaveBeenCalled();
expect(result).toMatchObject({
modId: 'test-mod-2',
name: directoryName,
directory: directoryPath,
remote: 'https://github.com/test/test-mod-2.git'
});
});
it('should replace any existing installation record at that directory', async () => {
const previous: ModInstallation = {
modId: 'test-mod-1',
name: directoryName,
directory: directoryPath,
remote: 'https://github.com/test/test-mod-1.git',
installedAt: new Date('2023-01-01'),
lastUpdated: new Date('2023-01-01')
};
mockConfig.getInstalledMods = vi.fn().mockReturnValue([previous]);
const result = await modManager.relinkMod(directoryName, 'test-mod-2', installDir);
expect(mockConfig.removeInstalledMod).toHaveBeenCalledWith('test-mod-1');
expect(result.installedAt).toEqual(new Date('2023-01-01'));
});
it('should throw when target mod is not in registry', async () => {
await expect(modManager.relinkMod(directoryName, 'unknown', installDir))
.rejects
.toThrow("Mod 'unknown' is not a known mod");
});
it('should throw when directory is not a git repository', async () => {
mockRepository.getRepositoryStatus = vi.fn().mockResolvedValue({ isValidRepo: false });
await expect(modManager.relinkMod(directoryName, 'test-mod-2', installDir))
.rejects
.toThrow(`Directory '${directoryName}' is not a valid git repository`);
});
it('should throw when target is already installed at a different directory', async () => {
mockConfig.findInstalledModByRemote = vi.fn().mockReturnValue({
modId: 'test-mod-2',
name: 'somewhere-else',
directory: '/test/mods/somewhere-else',
remote: 'https://github.com/test/test-mod-2.git',
installedAt: new Date(),
lastUpdated: new Date()
});
await expect(modManager.relinkMod(directoryName, 'test-mod-2', installDir))
.rejects
.toThrow("already installed at /test/mods/somewhere-else");
});
it('should refuse when working tree is dirty without force', async () => {
mockRepository.hasUncommittedChanges = vi.fn().mockResolvedValue(true);
await expect(modManager.relinkMod(directoryName, 'test-mod-2', installDir))
.rejects
.toThrow(/uncommitted changes.*--force/);
expect(mockRepository.setRemoteUrl).not.toHaveBeenCalled();
});
it('should proceed when force is set despite dirty working tree', async () => {
mockRepository.hasUncommittedChanges = vi.fn().mockResolvedValue(true);
await modManager.relinkMod(directoryName, 'test-mod-2', installDir, { force: true });
expect(mockRepository.setRemoteUrl).toHaveBeenCalled();
});
});
describe('findActiveSibling', () => {
const siblingTestMods: Mod[] = [
{ id: 'foo', name: 'foo', label: 'Foo', remote: 'https://x/foo.git', deprecated: true },
{ id: 'foo-16', name: 'foo-16', label: 'Foo 1.6', remote: 'https://x/foo-16.git' },
{ id: 'bar', name: 'bar', label: 'Bar', remote: 'https://x/bar.git', deprecated: true },
{ id: 'baz', name: 'baz', label: 'Baz', remote: 'https://x/baz.git' },
{ id: 'baz-9', name: 'baz-9', label: 'Baz 9', remote: 'https://x/baz-9.git' },
];
let manager: ModManager;
beforeEach(() => {
manager = new ModManager(mockConfig, mockRepository, siblingTestMods);
});
it('should find an active -16 sibling for a deprecated entry', () => {
const result = manager.findActiveSibling(siblingTestMods[0]);
expect(result).toEqual(siblingTestMods[1]);
});
it('should return undefined when no sibling exists', () => {
const result = manager.findActiveSibling(siblingTestMods[2]);
expect(result).toBeUndefined();
});
it('should return undefined for non-deprecated entries', () => {
const result = manager.findActiveSibling(siblingTestMods[1]);
expect(result).toBeUndefined();
});
it('should not match suffixes outside the -1[3-9] range', () => {
const result = manager.findActiveSibling(siblingTestMods[3]);
expect(result).toBeUndefined();
});
});
describe('generateInstallationReport', () => {
it('should generate comprehensive installation report', async () => {
vi.mocked(fs.readdir).mockResolvedValueOnce(['mod1', 'mod2', 'unknown'] as any);
mockRepository.getRepositoryStatus = vi.fn()
.mockResolvedValueOnce({
isValidRepo: true,
remoteUrl: 'https://github.com/test/test-mod-1.git'
})
.mockResolvedValueOnce({
isValidRepo: true,
remoteUrl: 'https://github.com/test/test-mod-2.git'
})
.mockResolvedValueOnce({
isValidRepo: false
});
const missingMod: ModInstallation = {
modId: 'missing',
name: 'missing',
directory: '/test/mods/missing',
remote: 'https://github.com/test/missing.git',
installedAt: new Date(),
lastUpdated: new Date()
};
mockConfig.getInstalledMods = vi.fn().mockReturnValue([missingMod]);
mockConfig.isModInstalled = vi.fn().mockReturnValue(false);
const report = await modManager.generateInstallationReport('/test/mods');
expect(report).toMatchObject({
totalMods: 3, // Number of mods in registry
installedMods: 1, // Number in config
missingMods: ['missing'],
unknownMods: ['unknown'],
recommendations: expect.arrayContaining([
expect.stringContaining('1 mod(s) are in config but missing from disk'),
expect.stringContaining('1 unknown folder(s) found'),
expect.stringContaining('2 new mod(s) found')
])
});
});
});
});