= ({
{installDir.installationType.toUpperCase()}
+
+
+ {installDir.ctpVersion ||
+ 'Unknown'}
+
+
{modsForInstall.length > 0 ? (
diff --git a/src/components/ReleaseNotesModal.tsx b/src/components/ReleaseNotesModal.tsx
new file mode 100644
index 0000000..d1e4084
--- /dev/null
+++ b/src/components/ReleaseNotesModal.tsx
@@ -0,0 +1,116 @@
+import React, { FC, useCallback, useState } from 'react';
+
+import { Modal } from './Modal';
+
+interface ReleaseNotesModalProps {
+ onClose: (dontShowAgain: boolean) => void;
+ open: boolean;
+}
+
+export const ReleaseNotesModal: FC
= ({
+ onClose,
+ open,
+}) => {
+ const [dontShowAgain, setDontShowAgain] = useState(false);
+
+ const handleClose = useCallback(() => {
+ onClose(dontShowAgain);
+ }, [dontShowAgain, onClose]);
+
+ const releaseNotes = [
+ 'đŻ **CTP1 Support**: Added support for Call to Power 1 alongside CTP2 (consider this support **unstable**, and see next bullet point)',
+ 'â **IMPORTANT**: Many CTP1 mods are still incompatible - Forever Future was successfully installed, however, and some mods will simply need repacked to work',
+ 'đĄïž **Enhanced Error Handling**: Improved error reporting and handling throughout the mod application process',
+ 'đ **Mods.json Management**: Better handling of mod tracking files with legacy format support',
+ 'đ **Permission Handling**: Enhanced mod installation process with better permission error handling',
+ 'đ§ **Code Quality**: Comprehensive test coverage additions and code formatting improvements',
+ 'đ **Documentation**: Updated development guidelines and contributor instructions',
+ 'đ **Bug Fixes**: Various stability improvements and issue resolutions',
+ ];
+
+ return (
+
+
+
+
+
+
+
+
New Release: v0.7.0 Beta
+
+ Thank you for using Call to Power Mod Manager!
+
+
+
+
+
+
+ What's New:
+
+
+ {releaseNotes.map((note, index) => (
+
+ âą
+
+ {note
+ .split(/\*\*(.*?)\*\*/g)
+ .map((part, i) =>
+ i % 2 === 1 ? (
+ {part}
+ ) : (
+ part
+ )
+ )}
+
+
+ ))}
+
+
+
+
+
+
+
+
+ setDontShowAgain(e.target.checked)
+ }
+ type="checkbox"
+ />
+
+ Don't show this again
+
+
+
+
+
+ );
+};
diff --git a/src/electron/file/applyModsToInstall.test.ts b/src/electron/file/applyModsToInstall.test.ts
index ea47770..a285087 100644
--- a/src/electron/file/applyModsToInstall.test.ts
+++ b/src/electron/file/applyModsToInstall.test.ts
@@ -6,6 +6,7 @@ import * as applyFileChanges from './applyFileChanges';
import { applyModsToInstallWithMerge } from './applyModsToInstall';
import * as getFileChangesToApplyMod from './getFileChangesToApplyMod';
import { isValidInstall } from './isValidInstall';
+import { LineChangeGroup } from './lineChangeGroup';
// import { DEFAULT_MOD_DIR } from '../constants';
vi.mock('fs');
@@ -25,7 +26,7 @@ describe('applyModsToInstall', () => {
vi.clearAllMocks();
});
- it('should log an error if the install directory is invalid', () => {
+ it('should log an error if the install directory is invalid', async () => {
expect.assertions(1);
const consoleErrorSpy = vi
.spyOn(console, 'error')
@@ -33,7 +34,7 @@ describe('applyModsToInstall', () => {
vi.spyOn(fs, 'readdirSync').mockReturnValueOnce([]);
- applyModsToInstallWithMerge('/invalid/install', ['mod1']);
+ await applyModsToInstallWithMerge('/invalid/install', ['mod1']);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Invalid install passed to applyModsToInstall! Install passed: /invalid/install'
@@ -156,7 +157,7 @@ describe('applyModsToInstall', () => {
it('should log an error if the install directory is invalid', async () => {
expect.assertions(2);
- vi.mocked(isValidInstall).mockReturnValue(false);
+ vi.mocked(isValidInstall).mockResolvedValue(false);
await applyModsToInstallWithMerge('/invalid/install', ['mod1']);
@@ -170,7 +171,7 @@ describe('applyModsToInstall', () => {
it('should log an error if a mod is not a directory', async () => {
expect.assertions(1);
- vi.mocked(isValidInstall).mockReturnValue(true);
+ vi.mocked(isValidInstall).mockResolvedValue(true);
vi.spyOn(fs, 'statSync').mockReturnValueOnce({
isDirectory: () => false,
} as fs.Stats);
@@ -184,7 +185,7 @@ describe('applyModsToInstall', () => {
it('should log an error if there is an issue getting the stats for a mod directory', async () => {
expect.assertions(1);
- vi.mocked(isValidInstall).mockReturnValue(true);
+ vi.mocked(isValidInstall).mockResolvedValue(true);
vi.spyOn(fs, 'statSync').mockImplementationOnce(() => {
throw new Error('stat error');
});
@@ -198,7 +199,7 @@ describe('applyModsToInstall', () => {
it('should apply changes for a single mod (Case 1: property replacement)', async () => {
expect.assertions(2);
- vi.mocked(isValidInstall).mockReturnValue(true);
+ vi.mocked(isValidInstall).mockResolvedValue(true);
vi.spyOn(fs, 'statSync').mockReturnValue({
isDirectory: () => true,
} as fs.Stats);
@@ -208,9 +209,11 @@ describe('applyModsToInstall', () => {
fileName: 'test.ts',
lineChangeGroups: [
{
- endLine: 6,
- replacementLines: [' magicka: number;'],
- startLine: 4,
+ changeType: 'replace' as const,
+ endLineNumber: 6,
+ newContent: ' magicka: number;',
+ oldContent: '',
+ startLineNumber: 4,
},
],
},
@@ -238,7 +241,7 @@ describe('applyModsToInstall', () => {
it('should apply changes for a single mod (Case 2: property reordering)', async () => {
expect.assertions(1);
- vi.mocked(isValidInstall).mockReturnValue(true);
+ vi.mocked(isValidInstall).mockResolvedValue(true);
vi.spyOn(fs, 'statSync').mockReturnValue({
isDirectory: () => true,
} as fs.Stats);
@@ -248,13 +251,12 @@ describe('applyModsToInstall', () => {
fileName: 'test.ts',
lineChangeGroups: [
{
- endLine: 5,
- replacementLines: [
- ' happiness: number;',
- ' health: number;',
- ' stamina: number;',
- ],
- startLine: 2,
+ changeType: 'replace' as const,
+ endLineNumber: 5,
+ newContent:
+ ' happiness: number;\n health: number;\n stamina: number;',
+ oldContent: '',
+ startLineNumber: 2,
},
],
},
@@ -279,7 +281,7 @@ describe('applyModsToInstall', () => {
it('should apply changes for a single mod (Case 3: property replacement with similar name)', async () => {
expect.assertions(1);
- vi.mocked(isValidInstall).mockReturnValue(true);
+ vi.mocked(isValidInstall).mockResolvedValue(true);
vi.spyOn(fs, 'statSync').mockReturnValue({
isDirectory: () => true,
} as fs.Stats);
@@ -289,9 +291,11 @@ describe('applyModsToInstall', () => {
fileName: 'test.ts',
lineChangeGroups: [
{
- endLine: 4,
- replacementLines: [' happiness: number;'],
- startLine: 4,
+ changeType: 'replace' as const,
+ endLineNumber: 4,
+ newContent: ' happiness: number;',
+ oldContent: '',
+ startLineNumber: 4,
},
],
},
@@ -316,7 +320,7 @@ describe('applyModsToInstall', () => {
it('should apply changes for a single mod (Case 4: multiple changes with reordering)', async () => {
expect.assertions(1);
- vi.mocked(isValidInstall).mockReturnValue(true);
+ vi.mocked(isValidInstall).mockResolvedValue(true);
vi.spyOn(fs, 'statSync').mockReturnValue({
isDirectory: () => true,
} as fs.Stats);
@@ -326,22 +330,12 @@ describe('applyModsToInstall', () => {
fileName: 'test.ts',
lineChangeGroups: [
{
- endLine: 13,
- replacementLines: [
- 'interface TestInterface {',
- ' health: number;',
- ' isInvulnerable: true;',
- ' stamina: number;',
- " customChar: 'Dave';",
- ' happiness: number;',
- '',
- ' }',
- '',
- ' function IsCool() {',
- ' return true;',
- ' }',
- ],
- startLine: 1,
+ changeType: 'replace' as const,
+ endLineNumber: 13,
+ newContent:
+ "interface TestInterface {\n health: number;\n isInvulnerable: true;\n stamina: number;\n customChar: 'Dave';\n happiness: number;\n\n }\n\n function IsCool() {\n return true;\n }",
+ oldContent: '',
+ startLineNumber: 1,
},
],
},
@@ -366,7 +360,7 @@ describe('applyModsToInstall', () => {
it('should apply changes for multiple mods sequentially (Case 5)', async () => {
expect.assertions(4);
- vi.mocked(isValidInstall).mockReturnValue(true);
+ vi.mocked(isValidInstall).mockResolvedValue(true);
vi.spyOn(fs, 'statSync').mockReturnValue({
isDirectory: () => true,
} as fs.Stats);
@@ -376,22 +370,12 @@ describe('applyModsToInstall', () => {
fileName: 'test.ts',
lineChangeGroups: [
{
- endLine: 15,
- replacementLines: [
- 'interface TestInterface {',
- ' health: number;',
- ' isInvulnerable: true;',
- ' stamina: number;',
- " customChar: 'Dave';",
- ' happiness: number;',
- '',
- ' }',
- '',
- ' function IsCool() {',
- ' return true;',
- ' }',
- ],
- startLine: 1,
+ changeType: 'replace' as const,
+ endLineNumber: 15,
+ newContent:
+ "interface TestInterface {\n health: number;\n isInvulnerable: true;\n stamina: number;\n customChar: 'Dave';\n happiness: number;\n\n }\n\n function IsCool() {\n return true;\n }",
+ oldContent: '',
+ startLineNumber: 1,
},
],
},
@@ -402,9 +386,11 @@ describe('applyModsToInstall', () => {
fileName: 'test.ts',
lineChangeGroups: [
{
- endLine: 5,
- replacementLines: [' IsReallyCool: true;'],
- startLine: 5,
+ changeType: 'replace' as const,
+ endLineNumber: 5,
+ newContent: ' IsReallyCool: true;',
+ oldContent: '',
+ startLineNumber: 5,
},
],
},
@@ -416,7 +402,9 @@ describe('applyModsToInstall', () => {
vi.mocked(
getFileChangesToApplyMod.consolidateLineChangeGroups
- ).mockImplementation((groups) => groups);
+ ).mockImplementation((groups) => {
+ return groups as unknown as LineChangeGroup[];
+ });
await applyModsToInstallWithMerge('/valid/install', ['mod1', 'mod2']);
diff --git a/src/electron/file/applyModsToInstall.ts b/src/electron/file/applyModsToInstall.ts
index ccf91d4..94af9fa 100644
--- a/src/electron/file/applyModsToInstall.ts
+++ b/src/electron/file/applyModsToInstall.ts
@@ -9,6 +9,7 @@ import {
getFileChangesToApplyMod,
} from './getFileChangesToApplyMod';
import { isValidInstall } from './isValidInstall';
+import { ModApplicationError } from './modApplicationError';
// Define interface for the mod tracking data
interface AppliedMod {
@@ -36,9 +37,43 @@ const updateModsTrackingFile = (
if (fs.existsSync(modsFilePath)) {
try {
const fileContent = fs.readFileSync(modsFilePath, 'utf-8');
- modsData = JSON.parse(fileContent) as ModsTrackingFile;
- if (!modsData.appliedMods) {
- modsData.appliedMods = [];
+ const parsedData = JSON.parse(fileContent);
+
+ // Handle legacy format (array of strings) - convert to new format
+ if (Array.isArray(parsedData)) {
+ console.log('Converting legacy mods.json format to new format');
+ // Preserve legacy mods by converting them to new format
+ modsData = {
+ appliedMods: parsedData
+ .filter(
+ (modName) =>
+ typeof modName === 'string' &&
+ modName.trim() !== ''
+ )
+ .map((modName) => ({
+ appliedDate: new Date().toISOString(),
+ name: modName,
+ })),
+ };
+ } else if (
+ parsedData &&
+ typeof parsedData === 'object' &&
+ 'appliedMods' in parsedData
+ ) {
+ // Handle new format
+ modsData = parsedData as ModsTrackingFile;
+ if (
+ !modsData.appliedMods ||
+ !Array.isArray(modsData.appliedMods)
+ ) {
+ modsData.appliedMods = [];
+ }
+ } else {
+ // Unknown format, start fresh
+ console.log(
+ 'Unknown mods.json format, starting with fresh tracking file'
+ );
+ modsData = { appliedMods: [] };
}
} catch (err) {
console.error(`Error reading existing mods.json: ${err}`);
@@ -73,6 +108,9 @@ const updateModsTrackingFile = (
console.log(`Updated mods tracking file at ${modsFilePath}`);
} catch (err) {
console.error(`Failed to write mods tracking file: ${err}`);
+
+ // Re-throw the error so it can be handled by the calling function
+ throw err;
}
};
@@ -115,67 +153,129 @@ export const applyModsToInstall = async (
installDir: Readonly,
queuedMods: ReadonlyDeep
): Promise => {
- if (!isValidInstall(installDir)) {
- console.error(
- `Invalid install passed to applyModsToInstall! Install passed: ${installDir}`
- );
- return;
+ console.log(
+ `Starting applyModsToInstall with installDir: ${installDir}, queuedMods: ${JSON.stringify(queuedMods)}`
+ );
+
+ // Bug #6 Fix: Throw error instead of just logging and returning
+ if (!(await isValidInstall(installDir))) {
+ const errorMessage = `Invalid installation directory: ${installDir}`;
+ console.error(errorMessage);
+ throw new ModApplicationError(errorMessage);
}
- // Loop through mods and copy each one into the install dir, overwriting
- for await (const mod of queuedMods) {
- // modPath is where the mod was extracted in our mods folder.
- const modPath = path.join(DEFAULT_MOD_DIR, mod);
- let targetDir = installDir; // default for non-scenario mods
-
- // If this mod is a scenario mod then:
- if (hasScenarioStructure(modPath)) {
- // Set target to be in "Scenarios" folder in the install.
- targetDir = path.join(
- installDir,
- 'Scenarios',
- path.basename(modPath)
- );
- }
+ console.log(`Install directory is valid: ${installDir}`);
+
+ const errors: string[] = [];
+
+ // Bug #3 Fix: Process mods sequentially to prevent race conditions
+ for (const mod of queuedMods) {
+ console.log(`Processing mod: ${mod}`);
- let statsOfFile: fs.Stats | undefined;
try {
- statsOfFile = fs.statSync(modPath);
+ await processSingleMod(mod, installDir);
} catch (err) {
- console.error(
- `An error occurred while getting the stats for ${modPath}: ${err}`
- );
- return;
+ const errorMessage =
+ err instanceof Error ? err.message : String(err);
+ errors.push(`Failed to apply mod "${mod}": ${errorMessage}`);
+ console.error(`Error applying mod ${mod}: ${errorMessage}`);
}
+ }
- if (statsOfFile && !statsOfFile.isDirectory()) {
- console.error(`Error: ${modPath} is not a directory.`);
- return;
+ // Bug #6 Fix: Aggregate and propagate errors
+ if (errors.length > 0) {
+ if (errors.length === 1) {
+ throw new ModApplicationError(errors[0]);
+ } else {
+ throw new ModApplicationError(
+ `Multiple errors occurred during mod application:\n${errors.join('\n')}`
+ );
}
+ }
- try {
- console.log(`Copying ${modPath} to installation at ${targetDir}`);
- fs.cpSync(modPath, targetDir, {
- force: true,
- recursive: true,
- });
- } catch (err) {
- console.error(`Error copying ${modPath} to ${targetDir}: ${err}`);
+ // Update tracking file only if all mods were successfully applied
+ try {
+ updateModsTrackingFile(installDir, [...queuedMods]);
+ console.log('All mods copied to the install directory.');
+ } catch (err) {
+ // Bug #2 & #6 Fix: Propagate tracking file errors to UI
+ if (err instanceof Error && err.message.includes('EPERM')) {
+ const isWindowsProgramFiles = installDir
+ .toLowerCase()
+ .includes('program files');
+ const errorMessage = isWindowsProgramFiles
+ ? `Permission denied: Cannot write mods tracking file to "${installDir}". This appears to be a Windows Program Files directory which requires administrator privileges. The mod files may have been copied, but tracking information couldn't be saved.`
+ : `Permission denied: Cannot write mods tracking file to "${installDir}". Please check that you have write permissions to this location.`;
+
+ throw new ModApplicationError(errorMessage);
}
+ throw new ModApplicationError(
+ `Failed to update mods tracking file: ${err}`
+ );
}
+};
- // After applying all mods, write the mod list to mods.json
+/**
+ * Processes a single mod application
+ * @param mod - The mod name to process
+ * @param installDir - The installation directory
+ */
+const processSingleMod = async (
+ mod: string,
+ installDir: string
+): Promise => {
+ const modPath = path.join(DEFAULT_MOD_DIR, mod);
+ console.log(`Mod path: ${modPath}`);
+ let targetDir = installDir; // default for non-scenario mods
+
+ // If this mod is a scenario mod then:
+ if (hasScenarioStructure(modPath)) {
+ console.log(`Detected scenario structure for mod: ${mod}`);
+ // Set target to be in "Scenarios" folder in the install.
+ targetDir = path.join(installDir, 'Scenarios', path.basename(modPath));
+ } else {
+ console.log(`No scenario structure detected for mod: ${mod}`);
+ }
+
+ let statsOfFile: fs.Stats | undefined;
try {
- const modsJsonPath = path.join(installDir, 'mods.json');
- fs.writeFileSync(modsJsonPath, JSON.stringify(queuedMods));
+ statsOfFile = fs.statSync(modPath);
+ console.log(`Successfully got stats for mod path: ${modPath}`);
} catch (err) {
- console.error(`Error writing mods.json: ${err}`);
+ throw new Error(`Cannot access mod directory "${modPath}": ${err}`);
}
- // After all mods have been applied, update the tracking file
- updateModsTrackingFile(installDir, [...queuedMods]);
+ if (statsOfFile && !statsOfFile.isDirectory()) {
+ throw new Error(`Mod path "${modPath}" is not a directory`);
+ }
+
+ console.log(`Mod path is a valid directory: ${modPath}`);
+
+ try {
+ console.log(`Copying ${modPath} to installation at ${targetDir}`);
+ fs.cpSync(modPath, targetDir, {
+ force: true,
+ recursive: true,
+ });
+ console.log(`Successfully copied ${modPath} to ${targetDir}`);
+ } catch (err) {
+ console.error(`Error copying ${modPath} to ${targetDir}: ${err}`);
+
+ // Bug #2 Fix: Throw proper errors for permission issues
+ if (err instanceof Error && err.message.includes('EPERM')) {
+ const isWindowsProgramFiles = targetDir
+ .toLowerCase()
+ .includes('program files');
+ const errorMessage = isWindowsProgramFiles
+ ? `Permission denied: Cannot write to "${targetDir}". This appears to be a Windows Program Files directory which requires administrator privileges. Please either:\n\n1. Run the CTP Mod Manager as Administrator, or\n2. Install Call to Power to a different location (like C:\\Games\\CallToPower) that doesn't require admin rights\n\nAlternatively, you can manually copy the mod files from:\n"${modPath}"\nto:\n"${targetDir}"`
+ : `Permission denied: Cannot write to "${targetDir}". Please check that:\n\n1. The directory is not read-only\n2. No files in the directory are currently in use\n3. You have write permissions to this location\n\nYou may need to run the application as Administrator if the installation is in a protected system directory.`;
+
+ throw new ModApplicationError(errorMessage);
+ }
- console.log('All mods copied to the install directory.');
+ // For other errors, throw a generic error
+ throw new Error(`Error copying mod files: ${err}`);
+ }
};
/**
@@ -194,7 +294,7 @@ export const applyModsToInstallWithMerge = async (
installDir: Readonly,
queuedMods: ReadonlyDeep
): Promise => {
- if (!isValidInstall(installDir)) {
+ if (!(await isValidInstall(installDir))) {
console.error(
`Invalid install passed to applyModsToInstall! Install passed: ${installDir}`
);
@@ -253,17 +353,20 @@ export const applyModsToInstallWithMerge = async (
// First consolidate line change groups within each file change object
changesArr = changesArr.map((modFileChange) => {
- const consolidatedFileChanges = modFileChange.fileChanges.map(
- (fileChange) => {
- if ('lineChangeGroups' in fileChange) {
- fileChange.lineChangeGroups = consolidateLineChangeGroups(
- fileChange.lineChangeGroups
- );
- }
- return fileChange;
- }
- );
-
+ const consolidatedFileChanges = Array.isArray(modFileChange.fileChanges)
+ ? modFileChange.fileChanges.map((fileChange) => {
+ if (
+ fileChange &&
+ 'lineChangeGroups' in fileChange &&
+ Array.isArray(fileChange.lineChangeGroups)
+ ) {
+ fileChange.lineChangeGroups = consolidateLineChangeGroups(
+ fileChange.lineChangeGroups
+ );
+ }
+ return fileChange;
+ })
+ : [];
return {
...modFileChange,
fileChanges: consolidatedFileChanges,
diff --git a/src/electron/file/copyFileToModDir.ts b/src/electron/file/copyFileToModDir.ts
index 3604e14..5008168 100644
--- a/src/electron/file/copyFileToModDir.ts
+++ b/src/electron/file/copyFileToModDir.ts
@@ -7,6 +7,7 @@ import { ReadonlyDeep } from 'type-fest';
import { DEFAULT_MOD_DIR, DEFAULT_MOD_FOLDER_NAME } from '../constants';
import { hasScenarioStructure } from './applyModsToInstall';
+import { GAME_DATA_DIRS } from './ctpVariants';
import { processGamefileMods } from './processGamefileMods';
export const unzipInModDir = async (
@@ -38,9 +39,9 @@ export const unzipInModDir = async (
};
/**
- * Finds and returns an array of directory paths that contain the "ctp2_data" folder within the specified directory.
+ * Finds and returns an array of directory paths that contain a CTP data folder (ctp2_data or ctp_data) within the specified directory.
* @param dir - The root directory to search within.
- * @returns An array of strings representing the paths to the "ctp2_data" folders found within the specified directory.
+ * @returns An array of strings representing the paths to the data folders found within the specified directory.
* @throws Will log an error message if an error occurs during the search process.
*/
const findGameRootsWithinDir = (dir: string): string[] => {
@@ -53,21 +54,28 @@ const findGameRootsWithinDir = (dir: string): string[] => {
return dirs;
}
- // Original code for non-scenario mods
- const ctp2DataPaths = klawSync(dir)
- .filter((file) => file.path.includes('ctp2_data'))
- .map((dirWithData) => dirWithData.path.split('ctp2_data')[0]);
-
- ctp2DataPaths.forEach((path) => {
- if (!dirs.includes(path + 'ctp2_data'))
- dirs.push(path + 'ctp2_data');
+ // Support both ctp2_data and ctp_data
+ const dataPaths = klawSync(dir)
+ .filter((file) => GAME_DATA_DIRS.some((d) => file.path.includes(d)))
+ .map((dirWithData) => {
+ for (const d of GAME_DATA_DIRS) {
+ if (dirWithData.path.includes(d)) {
+ // Return the full path to the data directory, not just the parent
+ const dataIndex = dirWithData.path.indexOf(d);
+ return dirWithData.path.substring(
+ 0,
+ dataIndex + d.length
+ );
+ }
+ }
+ return dirWithData.path;
+ });
+ dataPaths.forEach((path) => {
+ if (!dirs.includes(path)) dirs.push(path);
});
} catch (err) {
- console.error(
- `An error occurred while searching for game data folders: ${err}`
- );
+ console.error(`Error finding game roots: ${err}`);
}
-
return dirs;
};
@@ -242,20 +250,23 @@ const copyDataFoldersToModDirs = (
};
/**
- * Copies the contents of a directory ending with 'ctp2_data' to a mod directory.
- * @param dir - The directory path that ends with 'ctp2_data'.
- * @throws Will throw an error if the directory does not end with 'ctp2_data'.
+ * Copies the contents of a directory ending with 'ctp2_data' or 'ctp_data' to a mod directory.
+ * @param dir - The directory path that ends with 'ctp2_data' or 'ctp_data'.
+ * @throws Will throw an error if the directory does not end with a valid CTP data directory name.
*
* The function extracts the parent directory name of the provided directory,
- * removes the 'ctp2_data' suffix, and then copies the contents of the resulting
+ * removes the data directory suffix, and then copies the contents of the resulting
* directory to a predefined mod directory.
*
* If an error occurs during the copy operation, it logs the error to the console.
*/
const copyDataFolderToModDir = (dir: string): void => {
- if (!dir.endsWith('ctp2_data')) {
+ const hasValidDataDir = GAME_DATA_DIRS.some((dataDir) =>
+ dir.endsWith(dataDir)
+ );
+ if (!hasValidDataDir) {
throw new Error(
- `Dir passed to copyDataFolderToModDir that does not end with ctp2_data: ${dir}. Aborting.`
+ `Dir passed to copyDataFolderToModDir that does not end with a valid CTP data directory (${GAME_DATA_DIRS.join(', ')}): ${dir}. Aborting.`
);
}
diff --git a/src/electron/file/ctpVariants.ts b/src/electron/file/ctpVariants.ts
new file mode 100644
index 0000000..68ff1f2
--- /dev/null
+++ b/src/electron/file/ctpVariants.ts
@@ -0,0 +1,59 @@
+// Utility constants and helpers for CTP1/CTP2 support
+
+import path from 'path';
+
+export const GAME_EXECUTABLES = ['ctp2.exe', 'civctp.exe'];
+export const GAME_EXECUTABLES_UNIX = ['ctp2', 'civctp'];
+export const GAME_PROGRAM_DIRS = ['ctp2_program', 'ctp_program'];
+export const GAME_DATA_DIRS = ['ctp2_data', 'ctp_data'];
+
+/**
+ * Returns true if the file is a CTP executable (CTP1 or CTP2)
+ * @param file The file name
+ * @returns True if the file is a CTP executable
+ */
+export const isGameExecutable = (file: string): boolean => {
+ return (
+ GAME_EXECUTABLES.includes(file.toLowerCase()) ||
+ GAME_EXECUTABLES_UNIX.includes(file.toLowerCase())
+ );
+};
+
+/**
+ * Returns true if the directory is a CTP program directory (CTP1 or CTP2)
+ * @param dir The directory name
+ * @returns True if the directory is a CTP program directory
+ */
+export const isGameProgramDir = (dir: string): boolean => {
+ return GAME_PROGRAM_DIRS.includes(dir.toLowerCase());
+};
+
+/**
+ * Returns true if the directory is a CTP data directory (CTP1 or CTP2)
+ * @param dir The directory name
+ * @returns True if the directory is a CTP data directory
+ */
+export const isGameDataDir = (dir: string): boolean => {
+ return GAME_DATA_DIRS.includes(dir.toLowerCase());
+};
+
+/**
+ * Returns all possible executable paths for both CTP1 and CTP2
+ * @param installDir The installation directory
+ * @param platform The platform string (default: process.platform)
+ * @returns Array of possible executable paths
+ */
+export const getGameExecutablePath = (
+ installDir: string,
+ platform: string = process.platform
+): string[] => {
+ const paths: string[] = [];
+ for (const programDir of GAME_PROGRAM_DIRS) {
+ for (const exe of platform === 'win32'
+ ? GAME_EXECUTABLES
+ : GAME_EXECUTABLES_UNIX) {
+ paths.push(path.join(installDir, programDir, 'ctp', exe));
+ }
+ }
+ return paths;
+};
diff --git a/src/electron/file/detectCtpVersion.test.ts b/src/electron/file/detectCtpVersion.test.ts
new file mode 100644
index 0000000..db4e68a
--- /dev/null
+++ b/src/electron/file/detectCtpVersion.test.ts
@@ -0,0 +1,77 @@
+import fs from 'fs';
+import { describe, expect, it, vi } from 'vitest';
+
+import { detectCtpVersion } from './detectCtpVersion';
+
+vi.mock('fs');
+
+const mockedFs = vi.mocked(fs);
+
+describe('detectCtpVersion', () => {
+ it('should return "CTP1" when ctp_data directory exists', async () => {
+ expect.hasAssertions();
+ mockedFs.readdirSync.mockReturnValueOnce([
+ 'ctp_data',
+ 'ctp_program',
+ ] as never);
+ const result = await detectCtpVersion('/ctp1/install');
+ expect(result).toBe('CTP1');
+ });
+
+ it('should return "CTP2" when ctp2_data directory exists', async () => {
+ expect.hasAssertions();
+ mockedFs.readdirSync.mockReturnValueOnce([
+ 'ctp2_data',
+ 'ctp2_program',
+ ] as never);
+ const result = await detectCtpVersion('/ctp2/install');
+ expect(result).toBe('CTP2');
+ });
+
+ it('should return "CTP1" when only ctp_data exists (without ctp_program)', async () => {
+ expect.hasAssertions();
+ mockedFs.readdirSync.mockReturnValueOnce(['ctp_data'] as never);
+ const result = await detectCtpVersion('/ctp1/install');
+ expect(result).toBe('CTP1');
+ });
+
+ it('should return "CTP2" when only ctp2_data exists (without ctp2_program)', async () => {
+ expect.hasAssertions();
+ mockedFs.readdirSync.mockReturnValueOnce(['ctp2_data'] as never);
+ const result = await detectCtpVersion('/ctp2/install');
+ expect(result).toBe('CTP2');
+ });
+
+ it('should return "CTP2" when both ctp_data and ctp2_data exist (prioritize CTP2)', async () => {
+ expect.hasAssertions();
+ mockedFs.readdirSync.mockReturnValueOnce([
+ 'ctp_data',
+ 'ctp2_data',
+ ] as never);
+ const result = await detectCtpVersion('/mixed/install');
+ expect(result).toBe('CTP2');
+ });
+
+ it('should return "Unknown" when neither ctp_data nor ctp2_data exist', async () => {
+ expect.hasAssertions();
+ mockedFs.readdirSync.mockReturnValueOnce(['other_folder'] as never);
+ const result = await detectCtpVersion('/invalid/install');
+ expect(result).toBe('Unknown');
+ });
+
+ it('should return "Unknown" when directory is empty', async () => {
+ expect.hasAssertions();
+ mockedFs.readdirSync.mockReturnValueOnce([] as never);
+ const result = await detectCtpVersion('/empty/install');
+ expect(result).toBe('Unknown');
+ });
+
+ it('should handle readdir errors gracefully', async () => {
+ expect.hasAssertions();
+ mockedFs.readdirSync.mockImplementationOnce(() => {
+ throw new Error('Permission denied');
+ });
+ const result = await detectCtpVersion('/error/install');
+ expect(result).toBe('Unknown');
+ });
+});
diff --git a/src/electron/file/detectCtpVersion.ts b/src/electron/file/detectCtpVersion.ts
new file mode 100644
index 0000000..ccb7ad6
--- /dev/null
+++ b/src/electron/file/detectCtpVersion.ts
@@ -0,0 +1,32 @@
+import fs from 'fs';
+
+export type CtpVersion = 'CTP1' | 'CTP2' | 'Unknown';
+
+/**
+ * Detects whether an installation directory contains CTP1 or CTP2
+ * @param installDir The installation directory path
+ * @returns Promise resolving to 'CTP1', 'CTP2', or 'Unknown'
+ */
+export const detectCtpVersion = async (
+ installDir: string
+): Promise => {
+ try {
+ const contents = fs.readdirSync(installDir);
+ const lowerCaseContents = contents.map((item) => item.toLowerCase());
+
+ // Check for CTP2 data directory first (prioritize CTP2 if both exist)
+ if (lowerCaseContents.includes('ctp2_data')) {
+ return 'CTP2';
+ }
+
+ // Check for CTP1 data directory
+ if (lowerCaseContents.includes('ctp_data')) {
+ return 'CTP1';
+ }
+
+ return 'Unknown';
+ } catch (error) {
+ // Handle any file system errors gracefully
+ return 'Unknown';
+ }
+};
diff --git a/src/electron/file/enrichInstallDirsWithCtpVersion.test.ts b/src/electron/file/enrichInstallDirsWithCtpVersion.test.ts
new file mode 100644
index 0000000..918c9a1
--- /dev/null
+++ b/src/electron/file/enrichInstallDirsWithCtpVersion.test.ts
@@ -0,0 +1,112 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { InstallDirectory } from '../../App';
+import { detectCtpVersion } from './detectCtpVersion';
+import { enrichInstallDirsWithCtpVersion } from './enrichInstallDirsWithCtpVersion';
+
+vi.mock('./detectCtpVersion');
+const mockedDetectCtpVersion = vi.mocked(detectCtpVersion);
+
+describe('enrichInstallDirsWithCtpVersion', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+ it('should add CTP version information to each installation directory', async () => {
+ expect.hasAssertions();
+
+ const installDirs: InstallDirectory[] = [
+ {
+ directory: '/ctp1/install',
+ installationType: 'steam',
+ os: 'win32',
+ },
+ {
+ directory: '/ctp2/install',
+ installationType: 'gog',
+ os: 'linux',
+ },
+ ];
+
+ mockedDetectCtpVersion
+ .mockResolvedValueOnce('CTP1')
+ .mockResolvedValueOnce('CTP2');
+
+ const result = await enrichInstallDirsWithCtpVersion(installDirs);
+
+ expect(result).toHaveLength(2);
+ expect(result[0]).toMatchObject({
+ ctpVersion: 'CTP1',
+ directory: '/ctp1/install',
+ installationType: 'steam',
+ os: 'win32',
+ });
+ expect(result[1]).toMatchObject({
+ ctpVersion: 'CTP2',
+ directory: '/ctp2/install',
+ installationType: 'gog',
+ os: 'linux',
+ });
+ expect(detectCtpVersion).toHaveBeenCalledTimes(2);
+ expect(detectCtpVersion).toHaveBeenCalledWith('/ctp1/install');
+ expect(detectCtpVersion).toHaveBeenCalledWith('/ctp2/install');
+ });
+
+ it('should handle empty installation directories array', async () => {
+ expect.hasAssertions();
+
+ const installDirs: InstallDirectory[] = [];
+ const result = await enrichInstallDirsWithCtpVersion(installDirs);
+
+ expect(result).toHaveLength(0);
+ expect(detectCtpVersion).not.toHaveBeenCalled();
+ });
+
+ it('should handle detection errors gracefully by setting version to Unknown', async () => {
+ expect.hasAssertions();
+
+ const installDirs: InstallDirectory[] = [
+ {
+ directory: '/error/install',
+ installationType: 'steam',
+ os: 'win32',
+ },
+ ];
+
+ mockedDetectCtpVersion.mockResolvedValueOnce('Unknown');
+
+ const result = await enrichInstallDirsWithCtpVersion(installDirs);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({
+ ctpVersion: 'Unknown',
+ directory: '/error/install',
+ installationType: 'steam',
+ os: 'win32',
+ });
+ });
+
+ it('should preserve all original properties when adding CTP version', async () => {
+ expect.hasAssertions();
+
+ const installDirs: InstallDirectory[] = [
+ {
+ directory: '/wsl/install',
+ installationType: 'steam',
+ isWSL: true,
+ os: 'linux',
+ },
+ ];
+
+ mockedDetectCtpVersion.mockResolvedValueOnce('CTP2');
+
+ const result = await enrichInstallDirsWithCtpVersion(installDirs);
+
+ expect(result[0]).toMatchObject({
+ ctpVersion: 'CTP2',
+ directory: '/wsl/install',
+ installationType: 'steam',
+ isWSL: true,
+ os: 'linux',
+ });
+ });
+});
diff --git a/src/electron/file/enrichInstallDirsWithCtpVersion.ts b/src/electron/file/enrichInstallDirsWithCtpVersion.ts
new file mode 100644
index 0000000..09622e0
--- /dev/null
+++ b/src/electron/file/enrichInstallDirsWithCtpVersion.ts
@@ -0,0 +1,25 @@
+import { ReadonlyDeep } from 'type-fest';
+
+import { InstallDirectory } from '../../App';
+import { detectCtpVersion } from './detectCtpVersion';
+
+/**
+ * Enriches an array of installation directories with CTP version information
+ * @param installDirs Array of installation directories to enrich
+ * @returns Promise resolving to the same array but with ctpVersion property added
+ */
+export const enrichInstallDirsWithCtpVersion = async (
+ installDirs: ReadonlyDeep
+): Promise => {
+ const enrichedDirs = await Promise.all(
+ installDirs.map(async (dir) => {
+ const ctpVersion = await detectCtpVersion(dir.directory);
+ return {
+ ...dir,
+ ctpVersion,
+ };
+ })
+ );
+
+ return enrichedDirs;
+};
diff --git a/src/electron/file/getAppliedMods.test.ts b/src/electron/file/getAppliedMods.test.ts
new file mode 100644
index 0000000..5d99010
--- /dev/null
+++ b/src/electron/file/getAppliedMods.test.ts
@@ -0,0 +1,231 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { getAppliedMods } from './getAppliedMods';
+
+// Mock fs module
+vi.mock('fs');
+
+describe('getAppliedMods', () => {
+ const mockInstallDir = '/test/install/dir';
+ const mockModsJsonPath = path.join(mockInstallDir, 'mods.json');
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should return empty array when mods.json does not exist', () => {
+ expect.hasAssertions();
+
+ vi.mocked(fs.existsSync).mockReturnValue(false);
+
+ const result = getAppliedMods(mockInstallDir);
+
+ expect(result).toStrictEqual([]);
+ expect(fs.existsSync).toHaveBeenCalledWith(mockModsJsonPath);
+ });
+
+ it('should return mod names from array format (legacy format)', () => {
+ expect.hasAssertions();
+
+ const mockMods = ['mod1', 'mod2', 'mod3'];
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockMods));
+
+ const result = getAppliedMods(mockInstallDir);
+
+ expect(result).toStrictEqual(mockMods);
+ });
+
+ it('should return mod names from object format with appliedMods array (new format)', () => {
+ expect.hasAssertions();
+
+ const mockModsData = {
+ appliedMods: [
+ { appliedDate: '2023-01-01T00:00:00.000Z', name: 'mod1' },
+ { appliedDate: '2023-01-02T00:00:00.000Z', name: 'mod2' },
+ { appliedDate: '2023-01-03T00:00:00.000Z', name: 'mod3' },
+ ],
+ };
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockReturnValue(
+ JSON.stringify(mockModsData)
+ );
+
+ const result = getAppliedMods(mockInstallDir);
+
+ expect(result).toStrictEqual(['mod1', 'mod2', 'mod3']);
+ });
+
+ it('should return empty array when object format has no appliedMods property', () => {
+ expect.hasAssertions();
+
+ const mockModsData = { someOtherProperty: 'value' };
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockReturnValue(
+ JSON.stringify(mockModsData)
+ );
+
+ const result = getAppliedMods(mockInstallDir);
+
+ expect(result).toStrictEqual([]);
+ });
+
+ it('should return empty array when object format has empty appliedMods array', () => {
+ expect.hasAssertions();
+
+ const mockModsData: { appliedMods: never[] } = { appliedMods: [] };
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockReturnValue(
+ JSON.stringify(mockModsData)
+ );
+
+ const result = getAppliedMods(mockInstallDir);
+
+ expect(result).toStrictEqual([]);
+ });
+
+ it('should handle invalid JSON and return empty array', () => {
+ expect.hasAssertions();
+
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockReturnValue('invalid json');
+
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {
+ // Mock console.error to avoid test output noise
+ });
+
+ const result = getAppliedMods(mockInstallDir);
+
+ expect(result).toStrictEqual([]);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Error parsing mods.json')
+ );
+ });
+
+ it('should handle file read error and return empty array', () => {
+ expect.hasAssertions();
+
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
+ throw new Error('File read error');
+ });
+
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {
+ // Mock console.error to avoid test output noise
+ });
+
+ const result = getAppliedMods(mockInstallDir);
+
+ expect(result).toStrictEqual([]);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Error reading mods.json')
+ );
+ });
+
+ it('should handle object format with malformed appliedMods entries', () => {
+ expect.hasAssertions();
+
+ const mockModsData = {
+ appliedMods: [
+ { appliedDate: '2023-01-01T00:00:00.000Z', name: 'mod1' },
+ { name: 'mod2' }, // Missing appliedDate - should still work
+ { appliedDate: '2023-01-03T00:00:00.000Z' }, // Missing name - should be skipped
+ { appliedDate: '2023-01-04T00:00:00.000Z', name: 'mod3' },
+ ],
+ };
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockReturnValue(
+ JSON.stringify(mockModsData)
+ );
+
+ const result = getAppliedMods(mockInstallDir);
+
+ expect(result).toStrictEqual(['mod1', 'mod2', 'mod3']);
+ });
+
+ it('should handle mixed format gracefully (neither array nor object with appliedMods)', () => {
+ expect.hasAssertions();
+
+ const mockInvalidData = 'just a string';
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockReturnValue(
+ JSON.stringify(mockInvalidData)
+ );
+
+ const result = getAppliedMods(mockInstallDir);
+
+ expect(result).toStrictEqual([]);
+ });
+
+ describe('format consistency integration tests', () => {
+ it('should be compatible with the format written by updateModsTrackingFile from applyModsToInstall', () => {
+ expect.hasAssertions();
+
+ // Simulate the format that updateModsTrackingFile would write
+ const simulatedWrittenFormat = {
+ appliedMods: [
+ { appliedDate: '2023-01-01T00:00:00.000Z', name: 'mod1' },
+ { appliedDate: '2023-01-02T00:00:00.000Z', name: 'mod2' },
+ ],
+ };
+
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockReturnValue(
+ JSON.stringify(simulatedWrittenFormat)
+ );
+
+ const result = getAppliedMods(mockInstallDir);
+
+ expect(result).toStrictEqual(['mod1', 'mod2']);
+ });
+
+ it('should handle when legacy format is read after new format was expected to be written', () => {
+ expect.hasAssertions();
+
+ // This could happen if there's a bug in updateModsTrackingFile or external modification
+ const legacyFormat = ['mod1', 'mod2', 'mod3'];
+
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockReturnValue(
+ JSON.stringify(legacyFormat)
+ );
+
+ const result = getAppliedMods(mockInstallDir);
+
+ expect(result).toStrictEqual(['mod1', 'mod2', 'mod3']);
+ });
+
+ it('should handle transitional state where old format gets updated to new format', () => {
+ expect.hasAssertions();
+
+ // First call - legacy format
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockReturnValue(
+ JSON.stringify(['oldMod'])
+ );
+
+ const resultLegacy = getAppliedMods(mockInstallDir);
+ expect(resultLegacy).toStrictEqual(['oldMod']);
+
+ // Second call - new format (as if updateModsTrackingFile was called)
+ const newFormat = {
+ appliedMods: [
+ { appliedDate: '2023-01-01T00:00:00.000Z', name: 'oldMod' },
+ { appliedDate: '2023-01-02T00:00:00.000Z', name: 'newMod' },
+ ],
+ };
+ vi.mocked(fs.readFileSync).mockReturnValue(
+ JSON.stringify(newFormat)
+ );
+
+ const resultNew = getAppliedMods(mockInstallDir);
+ expect(resultNew).toStrictEqual(['oldMod', 'newMod']);
+ });
+ });
+});
diff --git a/src/electron/file/getAppliedMods.ts b/src/electron/file/getAppliedMods.ts
index b77b983..8c98d96 100644
--- a/src/electron/file/getAppliedMods.ts
+++ b/src/electron/file/getAppliedMods.ts
@@ -1,8 +1,19 @@
import * as fs from 'fs';
import * as path from 'path';
+// Types to handle both old and new formats
+interface AppliedMod {
+ appliedDate?: string;
+ name: string;
+}
+
+interface ModsTrackingFile {
+ appliedMods: AppliedMod[];
+}
+
/**
* Gets the names of mods applied to a specific installation directory
+ * Handles both legacy format (string[]) and new format ({ appliedMods: AppliedMod[] })
* @param installDir The installation directory to check
* @returns An array of mod names that have been applied to the installation
*/
@@ -14,9 +25,29 @@ export const getAppliedMods = (installDir: string): string[] => {
const modsData = fs.readFileSync(modsJsonPath, 'utf-8');
try {
const modsJson = JSON.parse(modsData);
+
+ // Handle legacy format (array of strings)
if (Array.isArray(modsJson)) {
return modsJson;
}
+
+ // Handle new format (object with appliedMods array)
+ if (
+ modsJson &&
+ typeof modsJson === 'object' &&
+ 'appliedMods' in modsJson
+ ) {
+ const trackingFile = modsJson as ModsTrackingFile;
+ if (Array.isArray(trackingFile.appliedMods)) {
+ return trackingFile.appliedMods
+ .filter(
+ (mod) => mod && typeof mod.name === 'string'
+ )
+ .map((mod) => mod.name);
+ }
+ }
+
+ // Unknown format, return empty array
return [];
} catch (err) {
console.error(`Error parsing mods.json: ${err}`);
diff --git a/src/electron/file/getFileChangesToApplyMod.test.ts b/src/electron/file/getFileChangesToApplyMod.test.ts
index ea8f130..fe4aaca 100644
--- a/src/electron/file/getFileChangesToApplyMod.test.ts
+++ b/src/electron/file/getFileChangesToApplyMod.test.ts
@@ -210,7 +210,7 @@ describe('consolidateLineChangeGroups', () => {
],
},
{
- fileName: 'ctp2_data/default/gamedata/Colors00.txt',
+ fileName: 'ctp_data/default/gamedata/Colors00.txt', // CTP1 variant
isBinary: false,
lineChangeGroups: [
{
@@ -236,7 +236,7 @@ describe('consolidateLineChangeGroups', () => {
],
},
{
- fileName: 'ctp2_data/default/gamedata/Colors00.txt',
+ fileName: 'ctp_data/default/gamedata/Colors00.txt', // CTP1 variant
isBinary: false,
lineChangeGroups: [
{
@@ -262,7 +262,7 @@ describe('consolidateLineChangeGroups', () => {
],
},
{
- fileName: 'ctp2_data/default/gamedata/Colors00.txt',
+ fileName: 'ctp_data/default/gamedata/Colors00.txt', // CTP1 variant
isBinary: false,
lineChangeGroups: [
{
diff --git a/src/electron/file/getGameExecutablePath.ts b/src/electron/file/getGameExecutablePath.ts
index 5378610..e1ecc0c 100644
--- a/src/electron/file/getGameExecutablePath.ts
+++ b/src/electron/file/getGameExecutablePath.ts
@@ -1,23 +1,25 @@
+import fs from 'fs';
import os from 'os';
-import path from 'path';
+
+import { getGameExecutablePath } from './ctpVariants';
/**
- * Gets the platform-specific path to the CTP2 executable
+ * Gets the platform-specific path to the CTP executable (CTP1 or CTP2)
* @param installDir The installation directory path
- * @returns The full path to the game executable
+ * @returns The first found full path to the game executable, or empty string if not found
*/
export const getCtp2ExecutablePath = (installDir: string): string => {
const platform = os.platform();
-
- // Build the relative path based on platform
- if (platform === 'win32') {
- // Windows uses backslashes and .exe extension
- return path.join(installDir, 'ctp2_program', 'ctp', 'ctp2.exe');
- } else if (platform === 'darwin') {
- // macOS
- return path.join(installDir, 'ctp2_program', 'ctp', 'ctp2');
- } else {
- // Linux and other Unix-like systems
- return path.join(installDir, 'ctp2_program', 'ctp', 'ctp2');
+ const possiblePaths = getGameExecutablePath(installDir, platform);
+ for (const exePath of possiblePaths) {
+ try {
+ if (fs.existsSync(exePath)) {
+ return exePath;
+ }
+ } catch (e) {
+ // ignore
+ }
}
+ // fallback to first possible path (for legacy behavior)
+ return possiblePaths[0] || '';
};
diff --git a/src/electron/file/isValidInstall.test.ts b/src/electron/file/isValidInstall.test.ts
index a83f9db..b11a687 100644
--- a/src/electron/file/isValidInstall.test.ts
+++ b/src/electron/file/isValidInstall.test.ts
@@ -5,54 +5,69 @@ import { isValidInstall } from './isValidInstall';
vi.mock('fs');
+const mockedFs = vi.mocked(fs);
+
describe('isValidInstall', () => {
it('should return true if ctp2_data directory exists', async () => {
expect.hasAssertions();
-
- // Mock for this test case
- // @ts-expect-error This is a mock
- vi.spyOn(fs, 'readdirSync').mockReturnValueOnce(['ctp2_data']); // Return the mock value
-
+ mockedFs.readdirSync.mockReturnValueOnce(['ctp2_data'] as never);
+ const result = await isValidInstall('/game');
+ expect(result).toBeTruthy();
+ });
+ it('should return true if ctp_data directory exists (CTP1)', async () => {
+ expect.hasAssertions();
+ mockedFs.readdirSync.mockReturnValueOnce(['ctp_data'] as never);
const result = await isValidInstall('/game');
expect(result).toBeTruthy();
});
-
it('should return false if directory is empty', async () => {
expect.hasAssertions();
-
- // Mock for this test case
- vi.spyOn(fs, 'readdirSync').mockReturnValueOnce([]); // Simulate empty directory
-
+ mockedFs.readdirSync.mockReturnValueOnce([] as never);
const result = await isValidInstall('/game');
expect(result).toBeFalsy();
});
-
it('should handle multiple directories and return true if ctp2_data exists', async () => {
expect.hasAssertions();
-
- // Mock for this test case
- vi.spyOn(fs, 'readdirSync').mockReturnValueOnce([
- // @ts-expect-error This is a mock
+ mockedFs.readdirSync.mockReturnValueOnce([
'ctp2_data',
- // @ts-expect-error This is a mock
'ctp2_program',
- ]); // Return the mock value
-
+ ] as never);
const result = await isValidInstall('/game');
expect(result).toBeTruthy();
});
-
- it('should handle multiple directories and return false if ctp2_data does not exist', async () => {
+ it('should handle multiple directories and return true if ctp_data exists (CTP1)', async () => {
expect.hasAssertions();
-
- // Mock for this test case
- vi.spyOn(fs, 'readdirSync').mockReturnValueOnce([
- // @ts-expect-error This is a mock
+ mockedFs.readdirSync.mockReturnValueOnce([
+ 'ctp_data',
+ 'ctp_program',
+ ] as never);
+ const result = await isValidInstall('/game');
+ expect(result).toBeTruthy();
+ });
+ it('should handle multiple directories and return true if ctp2_program exists', async () => {
+ expect.hasAssertions();
+ mockedFs.readdirSync.mockReturnValueOnce([
+ 'ctp2_data',
+ 'ctp2_program',
+ ] as never);
+ const result = await isValidInstall('/game');
+ expect(result).toBeTruthy();
+ });
+ it('should handle multiple directories and return true if ctp_program exists (CTP1)', async () => {
+ expect.hasAssertions();
+ mockedFs.readdirSync.mockReturnValueOnce([
+ 'ctp_data',
+ 'ctp_program',
+ ] as never);
+ const result = await isValidInstall('/game');
+ expect(result).toBeTruthy();
+ });
+ it('should handle multiple directories and return false if ctp2_data and ctp_data do not exist', async () => {
+ expect.hasAssertions();
+ mockedFs.readdirSync.mockReturnValueOnce([
'ctp2_program',
- // @ts-expect-error This is a mock
'data',
- ]); // Simulate non-existing directory
-
+ ] as never);
const result = await isValidInstall('/game');
expect(result).toBeFalsy();
});
diff --git a/src/electron/file/isValidInstall.ts b/src/electron/file/isValidInstall.ts
index 47b1c08..abb79b9 100644
--- a/src/electron/file/isValidInstall.ts
+++ b/src/electron/file/isValidInstall.ts
@@ -1,11 +1,13 @@
import fs from 'fs';
-const CTP2_CHECKED_DIR = 'ctp2_data';
+import { isGameDataDir } from './ctpVariants';
export const isValidInstall = async (dir: string): Promise => {
- // If there is a ctp2_data dir on the top level, it is a valid install
- return (
- fs.readdirSync(dir).filter((file) => file.endsWith(CTP2_CHECKED_DIR))
- .length > 0
+ // If there is a ctp2_data or ctp_data dir on the top level, it is a valid install
+ // Accept both string[] and Buffer[] for test and prod compatibility
+ const files = fs.readdirSync(dir, { withFileTypes: false });
+ const fileNames = files.map((f) =>
+ typeof f === 'string' ? f : Buffer.isBuffer(f) ? f.toString() : ''
);
+ return fileNames.some(isGameDataDir);
};
diff --git a/src/electron/file/modApplicationErrors.test.ts b/src/electron/file/modApplicationErrors.test.ts
new file mode 100644
index 0000000..1222000
--- /dev/null
+++ b/src/electron/file/modApplicationErrors.test.ts
@@ -0,0 +1,206 @@
+import * as fs from 'fs';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { applyModsToInstall } from './applyModsToInstall';
+import { isValidInstall } from './isValidInstall';
+
+vi.mock('fs');
+vi.mock('./isValidInstall', () => ({
+ isValidInstall: vi.fn(),
+}));
+
+vi.mock('electron', () => ({
+ app: {
+ getName: vi.fn().mockReturnValue('mock-name'),
+ getPath: vi.fn().mockReturnValue('/mock/path'),
+ },
+}));
+
+describe('mod application error handling', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.spyOn(console, 'log').mockImplementation(() => {
+ // Mock implementation
+ });
+ vi.spyOn(console, 'error').mockImplementation(() => {
+ // Mock implementation
+ });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('permission error propagation (Bug #2)', () => {
+ it('should throw ModApplicationError with detailed message for permission errors in Program Files', async () => {
+ expect.assertions(1);
+
+ vi.mocked(isValidInstall).mockResolvedValue(true);
+ vi.spyOn(fs, 'statSync').mockReturnValue({
+ isDirectory: () => true,
+ } as fs.Stats);
+
+ const mockError = new Error('EPERM: operation not permitted');
+ vi.spyOn(fs, 'cpSync').mockImplementation(() => {
+ throw mockError;
+ });
+
+ await expect(
+ applyModsToInstall('C:\\Program Files\\CallToPower2', [
+ 'testMod',
+ ])
+ ).rejects.toThrow(
+ 'Permission denied: Cannot write to "C:\\Program Files\\CallToPower2"'
+ );
+ });
+
+ it('should throw ModApplicationError with general message for permission errors in other locations', async () => {
+ expect.assertions(1);
+
+ vi.mocked(isValidInstall).mockResolvedValue(true);
+ vi.spyOn(fs, 'statSync').mockReturnValue({
+ isDirectory: () => true,
+ } as fs.Stats);
+
+ const mockError = new Error('EPERM: operation not permitted');
+ vi.spyOn(fs, 'cpSync').mockImplementation(() => {
+ throw mockError;
+ });
+
+ await expect(
+ applyModsToInstall('C:\\Games\\CallToPower2', ['testMod'])
+ ).rejects.toThrow(
+ 'Permission denied: Cannot write to "C:\\Games\\CallToPower2"'
+ );
+ });
+
+ it('should throw ModApplicationError for tracking file permission errors', async () => {
+ expect.assertions(1);
+
+ vi.mocked(isValidInstall).mockResolvedValue(true);
+ vi.spyOn(fs, 'statSync').mockReturnValue({
+ isDirectory: () => true,
+ } as fs.Stats);
+ vi.spyOn(fs, 'cpSync').mockImplementation(() => {
+ // File copy succeeds
+ });
+ vi.spyOn(fs, 'existsSync').mockReturnValue(false);
+
+ const mockError = new Error('EPERM: operation not permitted');
+ vi.spyOn(fs, 'writeFileSync').mockImplementation(() => {
+ throw mockError;
+ });
+
+ await expect(
+ applyModsToInstall('C:\\Program Files\\CallToPower2', [
+ 'testMod',
+ ])
+ ).rejects.toThrow(
+ 'Permission denied: Cannot write mods tracking file'
+ );
+ });
+ });
+
+ describe('concurrent operation handling (Bug #3)', () => {
+ it('should handle multiple mod applications sequentially to prevent race conditions', async () => {
+ expect.assertions(3);
+
+ vi.mocked(isValidInstall).mockResolvedValue(true);
+ vi.spyOn(fs, 'statSync').mockReturnValue({
+ isDirectory: () => true,
+ } as fs.Stats);
+
+ const cpSyncSpy = vi.spyOn(fs, 'cpSync').mockImplementation(() => {
+ // Simulate file operation delay
+ return new Promise((resolve) => setTimeout(resolve, 10));
+ });
+ vi.spyOn(fs, 'existsSync').mockReturnValue(false);
+ vi.spyOn(fs, 'writeFileSync').mockImplementation(() => {
+ // Mock implementation
+ });
+
+ await applyModsToInstall('/valid/install', [
+ 'mod1',
+ 'mod2',
+ 'mod3',
+ ]);
+
+ // Verify mods were processed sequentially (cpSync called 3 times)
+ expect(cpSyncSpy).toHaveBeenCalledTimes(3);
+
+ // Verify the order of operations
+ expect(cpSyncSpy).toHaveBeenNthCalledWith(
+ 1,
+ expect.stringContaining('mod1'),
+ '/valid/install',
+ expect.any(Object)
+ );
+ expect(cpSyncSpy).toHaveBeenNthCalledWith(
+ 2,
+ expect.stringContaining('mod2'),
+ '/valid/install',
+ expect.any(Object)
+ );
+ });
+ });
+
+ describe('error propagation to UI (Bug #6)', () => {
+ it('should propagate non-permission file errors to UI layer', async () => {
+ expect.assertions(1);
+
+ vi.mocked(isValidInstall).mockResolvedValue(true);
+ vi.spyOn(fs, 'statSync').mockReturnValue({
+ isDirectory: () => true,
+ } as fs.Stats);
+
+ const mockError = new Error('ENOENT: no such file or directory');
+ vi.spyOn(fs, 'cpSync').mockImplementation(() => {
+ throw mockError;
+ });
+
+ await expect(
+ applyModsToInstall('/valid/install', ['testMod'])
+ ).rejects.toThrow(
+ 'Failed to apply mod "testMod": Error copying mod files: Error: ENOENT: no such file or directory'
+ );
+ });
+
+ it('should propagate validation errors to UI layer', async () => {
+ expect.assertions(1);
+
+ vi.mocked(isValidInstall).mockResolvedValue(false);
+
+ await expect(
+ applyModsToInstall('/invalid/install', ['testMod'])
+ ).rejects.toThrow(
+ 'Invalid installation directory: /invalid/install'
+ );
+ });
+
+ it('should aggregate multiple mod errors and propagate to UI', async () => {
+ expect.assertions(1);
+
+ vi.mocked(isValidInstall).mockResolvedValue(true);
+ vi.spyOn(fs, 'statSync').mockReturnValue({
+ isDirectory: () => true,
+ } as fs.Stats);
+
+ let callCount = 0;
+ vi.spyOn(fs, 'cpSync').mockImplementation(() => {
+ callCount++;
+ if (callCount === 1) {
+ throw new Error('Error with mod1');
+ }
+ if (callCount === 2) {
+ throw new Error('Error with mod2');
+ }
+ });
+
+ await expect(
+ applyModsToInstall('/valid/install', ['mod1', 'mod2', 'mod3'])
+ ).rejects.toThrow(
+ 'Multiple errors occurred during mod application'
+ );
+ });
+ });
+});
diff --git a/src/electron/file/modsIntegration.test.ts b/src/electron/file/modsIntegration.test.ts
new file mode 100644
index 0000000..7e94c6a
--- /dev/null
+++ b/src/electron/file/modsIntegration.test.ts
@@ -0,0 +1,95 @@
+import * as fs from 'fs';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { getAppliedMods } from './getAppliedMods';
+
+vi.mock('fs');
+
+// Integration test to verify mods.json format consistency
+describe('mod application and tracking integration', () => {
+ const mockInstallDir = '/test/install/dir';
+ const mockMods = ['testMod1', 'testMod2'];
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should read legacy format and new format consistently', () => {
+ expect.hasAssertions();
+
+ // Test legacy format
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockMods));
+
+ const legacyResult = getAppliedMods(mockInstallDir);
+ expect(legacyResult).toStrictEqual(mockMods);
+
+ // Test new format by changing the mock return value
+ const newFormatData = {
+ appliedMods: mockMods.map((mod, index) => ({
+ appliedDate: `2023-01-0${index + 1}T00:00:00.000Z`,
+ name: mod,
+ })),
+ };
+ vi.mocked(fs.readFileSync).mockReturnValue(
+ JSON.stringify(newFormatData)
+ );
+
+ const newFormatResult = getAppliedMods(mockInstallDir);
+ expect(newFormatResult).toStrictEqual(mockMods);
+
+ // Both formats should return the same result
+ expect(legacyResult).toStrictEqual(newFormatResult);
+ });
+
+ it('should handle mixed data gracefully', () => {
+ expect.hasAssertions();
+
+ // Test case where some mods have metadata and others don't
+ const mixedData = {
+ appliedMods: [
+ { appliedDate: '2023-01-01T00:00:00.000Z', name: 'testMod1' },
+ { name: 'testMod2' }, // Missing appliedDate
+ { appliedDate: '2023-01-03T00:00:00.000Z' }, // Missing name - should be filtered out
+ { appliedDate: '2023-01-04T00:00:00.000Z', name: 'testMod3' },
+ ],
+ };
+
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mixedData));
+
+ const result = getAppliedMods(mockInstallDir);
+ expect(result).toStrictEqual(['testMod1', 'testMod2', 'testMod3']);
+ });
+
+ it('should preserve mod order from both formats', () => {
+ expect.hasAssertions();
+
+ const orderedMods = ['firstMod', 'secondMod', 'thirdMod'];
+
+ // Test legacy format preserves order
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(orderedMods));
+
+ const legacyResult = getAppliedMods(mockInstallDir);
+ expect(legacyResult).toStrictEqual(orderedMods);
+
+ // Test new format preserves order by changing mock return value
+ const newFormatData = {
+ appliedMods: orderedMods.map((mod, index) => ({
+ appliedDate: `2023-01-0${index + 1}T00:00:00.000Z`,
+ name: mod,
+ })),
+ };
+ vi.mocked(fs.readFileSync).mockReturnValue(
+ JSON.stringify(newFormatData)
+ );
+
+ const newFormatResult = getAppliedMods(mockInstallDir);
+ expect(newFormatResult).toStrictEqual(orderedMods);
+ });
+});
diff --git a/src/electron/file/removeFromInstallDirs.ts b/src/electron/file/removeFromInstallDirs.ts
index 6b66dcd..ac1ee6b 100644
--- a/src/electron/file/removeFromInstallDirs.ts
+++ b/src/electron/file/removeFromInstallDirs.ts
@@ -12,7 +12,7 @@ export const removeFromInstallDirs = async (dir: string): Promise => {
await ensureInstallFileExists(dir);
- let jsonFile: string[] = parseInstallFileIntoJSON();
+ const jsonFile: string[] = parseInstallFileIntoJSON();
if (!jsonFile.includes(dir)) {
// eslint-disable-next-line no-console
@@ -23,8 +23,7 @@ export const removeFromInstallDirs = async (dir: string): Promise => {
const index = jsonFile.indexOf(dir);
try {
- const newArr = jsonFile.splice(index - 1, 1);
- jsonFile = newArr;
+ jsonFile.splice(index, 1);
const dataToPush = JSON.stringify(jsonFile);
fs.writeFileSync(DEFAULT_INSTALLS_FILE, dataToPush);
} catch (err) {
diff --git a/src/electron/file/runGame.ts b/src/electron/file/runGame.ts
index 2b34c69..d6f8fe2 100644
--- a/src/electron/file/runGame.ts
+++ b/src/electron/file/runGame.ts
@@ -3,6 +3,8 @@ import { access, constants } from 'fs';
import { platform } from 'os';
import { ReadonlyDeep } from 'type-fest';
+import { GAME_EXECUTABLES } from './ctpVariants';
+
// Track the running game process
let gameProcess: ChildProcess | null = null;
// Track which installation directory is running
@@ -22,32 +24,30 @@ export const stopGame = (): boolean => {
console.log('game process: ', gameProcess);
if (platform() === 'win32') {
- const taskkill = spawn('taskkill', ['/f', '/im', 'ctp2.exe'], {
- shell: true,
- });
-
- taskkill.stdout.on('data', (data) => {
- console.log(`taskkill stdout: ${data}`);
- });
-
- taskkill.stderr.on('data', (data) => {
- console.error(`taskkill stderr: ${data}`);
- });
-
- taskkill.on('close', (code) => {
- if (code === 0) {
- console.log('Successfully killed process using taskkill');
- gameProcess = null;
- runningGameDir = null;
- } else {
- console.error(`taskkill exited with code ${code}`);
- }
- });
-
- taskkill.on('error', (err) => {
- console.error(`Failed to execute taskkill: ${err}`);
- });
-
+ // Support both ctp2.exe and civctp.exe
+ for (const exe of GAME_EXECUTABLES) {
+ const taskkill = spawn('taskkill', ['/f', '/im', exe], {
+ shell: true,
+ });
+
+ taskkill.stdout.on('data', (data) => {
+ console.log(`taskkill stdout: ${data}`);
+ });
+
+ taskkill.stderr.on('data', (data) => {
+ console.error(`taskkill stderr: ${data}`);
+ });
+
+ taskkill.on('close', (code) => {
+ if (code === 0) {
+ console.log(
+ `Successfully killed process ${exe} using taskkill`
+ );
+ gameProcess = null;
+ runningGameDir = null;
+ }
+ });
+ }
return true;
} else {
// Try to gracefully kill the process on non-Windows systems
diff --git a/src/electron/file/textFileChangesAreConflicting.test.ts b/src/electron/file/textFileChangesAreConflicting.test.ts
index a2bd848..5c0d8fa 100644
--- a/src/electron/file/textFileChangesAreConflicting.test.ts
+++ b/src/electron/file/textFileChangesAreConflicting.test.ts
@@ -21,7 +21,7 @@ describe('textFileChangesAreConflicting', () => {
],
},
{
- fileName: 'ctp2_data/default/gamedata/Colors00.txt',
+ fileName: 'ctp_data/default/gamedata/Colors00.txt', // CTP1 variant
isBinary: false,
lineChangeGroups: [
{
diff --git a/src/electron/file/updateModsTrackingFile.preserve.test.ts b/src/electron/file/updateModsTrackingFile.preserve.test.ts
new file mode 100644
index 0000000..14df039
--- /dev/null
+++ b/src/electron/file/updateModsTrackingFile.preserve.test.ts
@@ -0,0 +1,146 @@
+import * as fs from 'fs';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+// Import the function under test through applyModsToInstall
+import { applyModsToInstallWithMerge } from './applyModsToInstall';
+
+// Mock dependencies
+vi.mock('fs');
+
+vi.mock('electron', () => ({
+ app: {
+ getName: vi.fn().mockReturnValue('mock-name'),
+ getPath: vi.fn().mockReturnValue('/mock/path'),
+ },
+}));
+
+vi.mock('./isValidInstall', () => ({
+ isValidInstall: vi.fn(),
+}));
+vi.mock('./applyFileChanges', () => ({
+ applyFileChanges: vi.fn(),
+}));
+vi.mock('./getFileChangesToApplyMod', () => ({
+ consolidateLineChangeGroups: vi.fn((x) => x),
+ getFileChangesToApplyMod: vi.fn(),
+}));
+
+describe('updateModsTrackingFile - preserve legacy mods', () => {
+ const mockInstallDir = '/test/install/dir';
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.spyOn(console, 'log').mockImplementation(() => {
+ // Suppress console output during tests
+ });
+ vi.spyOn(console, 'error').mockImplementation(() => {
+ // Suppress console output during tests
+ });
+ });
+
+ it('should preserve existing mods when converting from legacy format to new format', async () => {
+ expect.assertions(3);
+
+ // Mock that install is valid and mod is a directory
+ const { isValidInstall } = await import('./isValidInstall');
+ vi.mocked(isValidInstall).mockResolvedValue(true);
+
+ vi.spyOn(fs, 'statSync').mockReturnValue({
+ isDirectory: () => true,
+ } as fs.Stats);
+
+ // Mock existing legacy mods.json with existing mods
+ const existingLegacyMods = ['oldMod1', 'oldMod2'];
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockReturnValue(
+ JSON.stringify(existingLegacyMods)
+ );
+
+ // Mock file operations
+ const writeFileSyncSpy = vi
+ .spyOn(fs, 'writeFileSync')
+ .mockImplementation(() => {
+ // Mock implementation
+ });
+
+ // Mock getFileChangesToApplyMod to return empty changes
+ const { getFileChangesToApplyMod } = await import(
+ './getFileChangesToApplyMod'
+ );
+ vi.mocked(getFileChangesToApplyMod).mockResolvedValue([]);
+
+ // Apply a new mod
+ await applyModsToInstallWithMerge(mockInstallDir, ['newMod']);
+
+ // Verify that writeFileSync was called
+ expect(writeFileSyncSpy).toHaveBeenCalledWith(
+ expect.stringContaining('mods.json'),
+ expect.any(String),
+ 'utf-8'
+ );
+
+ // Get the written data
+ const writtenData = writeFileSyncSpy.mock.calls[0][1] as string;
+ const parsedWrittenData = JSON.parse(writtenData);
+
+ // Should preserve existing mods AND add the new mod
+ expect(parsedWrittenData.appliedMods).toHaveLength(3);
+
+ const modNames = parsedWrittenData.appliedMods.map(
+ (mod: Readonly<{ name: string }>) => mod.name
+ );
+ expect(modNames).toStrictEqual(
+ expect.arrayContaining(['oldMod1', 'oldMod2', 'newMod'])
+ );
+ });
+
+ it('should preserve existing mods when there are duplicates in legacy format', async () => {
+ expect.assertions(3);
+
+ // Mock that install is valid and mod is a directory
+ const { isValidInstall } = await import('./isValidInstall');
+ vi.mocked(isValidInstall).mockResolvedValue(true);
+
+ vi.spyOn(fs, 'statSync').mockReturnValue({
+ isDirectory: () => true,
+ } as fs.Stats);
+
+ // Mock existing legacy mods.json with existing mods including one we're about to add
+ const existingLegacyMods = ['oldMod1', 'duplicateMod', 'oldMod2'];
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockReturnValue(
+ JSON.stringify(existingLegacyMods)
+ );
+
+ // Mock file operations
+ const writeFileSyncSpy = vi
+ .spyOn(fs, 'writeFileSync')
+ .mockImplementation(() => undefined);
+
+ // Mock getFileChangesToApplyMod to return empty changes
+ const { getFileChangesToApplyMod } = await import(
+ './getFileChangesToApplyMod'
+ );
+ vi.mocked(getFileChangesToApplyMod).mockResolvedValue([]);
+
+ // Apply a mod that already exists in legacy format
+ await applyModsToInstallWithMerge(mockInstallDir, ['duplicateMod']);
+
+ // Verify that writeFileSync was called
+ expect(writeFileSyncSpy).toHaveBeenCalledTimes(1);
+
+ // Verify the file path
+ const callArgs = writeFileSyncSpy.mock.calls[0];
+ expect(callArgs[0]).toContain('mods.json');
+
+ // Get the written data
+ const writtenData = callArgs[1] as string;
+ const parsedWrittenData = JSON.parse(writtenData);
+
+ // Should preserve existing mods but not duplicate
+ const modNames = parsedWrittenData.appliedMods.map(
+ (mod: Readonly<{ name: string }>) => mod.name
+ );
+ expect(modNames).toStrictEqual(['oldMod1', 'duplicateMod', 'oldMod2']);
+ });
+});
diff --git a/src/electron/file/updateModsTrackingFile.test.ts b/src/electron/file/updateModsTrackingFile.test.ts
new file mode 100644
index 0000000..dbce52f
--- /dev/null
+++ b/src/electron/file/updateModsTrackingFile.test.ts
@@ -0,0 +1,318 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+// We need to test the internal updateModsTrackingFile function
+// Since it's not exported, we'll need to test it through the exported functions that use it
+import { applyModsToInstallWithMerge } from './applyModsToInstall';
+import { getAppliedMods } from './getAppliedMods';
+
+// Mock dependencies
+vi.mock('fs');
+vi.mock('./isValidInstall', () => ({
+ isValidInstall: vi.fn(),
+}));
+vi.mock('./applyFileChanges', () => ({
+ applyFileChanges: vi.fn(),
+}));
+vi.mock('./getFileChangesToApplyMod', () => ({
+ consolidateLineChangeGroups: vi.fn((x) => x),
+ getFileChangesToApplyMod: vi.fn(),
+}));
+vi.mock('electron', () => ({
+ app: {
+ getName: vi.fn().mockReturnValue('mock-name'),
+ getPath: vi.fn().mockReturnValue('/mock/path'),
+ },
+}));
+
+describe('updateModsTrackingFile format consistency', () => {
+ const mockInstallDir = '/test/install/dir';
+ const mockModsJsonPath = path.join(mockInstallDir, 'mods.json');
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.spyOn(console, 'log').mockImplementation(() => {
+ // Mock console.log to avoid test output noise
+ });
+ vi.spyOn(console, 'error').mockImplementation(() => {
+ // Mock console.error to avoid test output noise
+ });
+ });
+
+ it('should write new format to mods.json when no existing file exists', async () => {
+ expect.hasAssertions();
+
+ // Mock that install is valid and mod is a directory
+ const { isValidInstall } = await import('./isValidInstall');
+ vi.mocked(isValidInstall).mockResolvedValue(true);
+
+ vi.spyOn(fs, 'statSync').mockReturnValue({
+ isDirectory: () => true,
+ } as fs.Stats);
+
+ // Mock that mods.json doesn't exist initially
+ vi.mocked(fs.existsSync).mockReturnValue(false);
+
+ // Mock file operations
+ const writeFileSyncSpy = vi
+ .spyOn(fs, 'writeFileSync')
+ .mockImplementation(() => {
+ // Mock writeFileSync to avoid actual file writes
+ });
+
+ // Mock getFileChangesToApplyMod to return empty changes
+ const { getFileChangesToApplyMod } = await import(
+ './getFileChangesToApplyMod'
+ );
+ vi.mocked(getFileChangesToApplyMod).mockResolvedValue([]);
+
+ await applyModsToInstallWithMerge(mockInstallDir, ['testMod']);
+
+ // Verify that writeFileSync was called with the new format
+ expect(writeFileSyncSpy).toHaveBeenCalledWith(
+ mockModsJsonPath,
+ expect.stringContaining('"appliedMods"'),
+ 'utf-8'
+ );
+
+ // Parse the written content to verify the format
+ const writtenData = writeFileSyncSpy.mock.calls[0][1] as string;
+ const parsedData = JSON.parse(writtenData);
+
+ expect(parsedData).toHaveProperty('appliedMods');
+ expect(Array.isArray(parsedData.appliedMods)).toBeTruthy();
+ expect(parsedData.appliedMods[0]).toHaveProperty('name', 'testMod');
+ expect(parsedData.appliedMods[0]).toHaveProperty('appliedDate');
+ expect(typeof parsedData.appliedMods[0].appliedDate).toBe('string');
+ });
+
+ it('should maintain new format when updating existing new format file', async () => {
+ expect.hasAssertions();
+
+ // Mock that install is valid and mod is a directory
+ const { isValidInstall } = await import('./isValidInstall');
+ vi.mocked(isValidInstall).mockResolvedValue(true);
+
+ vi.spyOn(fs, 'statSync').mockReturnValue({
+ isDirectory: () => true,
+ } as fs.Stats);
+
+ // Mock existing new format file
+ const existingData = {
+ appliedMods: [
+ {
+ appliedDate: '2023-01-01T00:00:00.000Z',
+ name: 'existingMod',
+ },
+ ],
+ };
+
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockReturnValue(
+ JSON.stringify(existingData)
+ );
+
+ const writeFileSyncSpy = vi
+ .spyOn(fs, 'writeFileSync')
+ .mockImplementation(() => {
+ // Mock writeFileSync to avoid actual file writes
+ });
+
+ // Mock getFileChangesToApplyMod to return empty changes
+ const { getFileChangesToApplyMod } = await import(
+ './getFileChangesToApplyMod'
+ );
+ vi.mocked(getFileChangesToApplyMod).mockResolvedValue([]);
+
+ await applyModsToInstallWithMerge(mockInstallDir, ['newMod']);
+
+ // Verify format is maintained
+ const writtenData = writeFileSyncSpy.mock.calls[0][1] as string;
+ const parsedData = JSON.parse(writtenData);
+
+ expect(parsedData).toHaveProperty('appliedMods');
+ expect(parsedData.appliedMods).toHaveLength(2);
+ expect(parsedData.appliedMods[0]).toHaveProperty('name', 'existingMod');
+ expect(parsedData.appliedMods[1]).toHaveProperty('name', 'newMod');
+ expect(parsedData.appliedMods[1]).toHaveProperty('appliedDate');
+ });
+
+ it('should convert legacy format to new format when updating', async () => {
+ expect.hasAssertions();
+
+ // Mock that install is valid and mod is a directory
+ const { isValidInstall } = await import('./isValidInstall');
+ vi.mocked(isValidInstall).mockResolvedValue(true);
+
+ vi.spyOn(fs, 'statSync').mockReturnValue({
+ isDirectory: () => true,
+ } as fs.Stats);
+
+ // Mock existing legacy format file
+ const legacyData = ['existingMod'];
+
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(legacyData));
+
+ const writeFileSyncSpy = vi
+ .spyOn(fs, 'writeFileSync')
+ .mockImplementation(() => {
+ // Mock writeFileSync to avoid actual file writes
+ });
+
+ // Mock getFileChangesToApplyMod to return empty changes
+ const { getFileChangesToApplyMod } = await import(
+ './getFileChangesToApplyMod'
+ );
+ vi.mocked(getFileChangesToApplyMod).mockResolvedValue([]);
+
+ await applyModsToInstallWithMerge(mockInstallDir, ['newMod']);
+
+ // Verify that the legacy format gets converted to new format
+ const writtenData = writeFileSyncSpy.mock.calls[0][1] as string;
+ const parsedData = JSON.parse(writtenData);
+
+ expect(parsedData).toHaveProperty('appliedMods');
+ expect(Array.isArray(parsedData.appliedMods)).toBeTruthy();
+ expect(parsedData.appliedMods).toHaveLength(2); // Both existing and new mod should be there
+
+ // Should have both mods
+ const modNames = parsedData.appliedMods.map(
+ (mod: Readonly<{ name: string }>) => mod.name
+ );
+ expect(modNames).toContain('existingMod'); // From legacy format
+ expect(modNames).toContain('newMod'); // Newly added
+
+ // Check that all mods have the required properties
+ expect(
+ parsedData.appliedMods.every(
+ (mod: Readonly<{ appliedDate: string; name: string }>) =>
+ typeof mod.name === 'string' &&
+ typeof mod.appliedDate === 'string'
+ )
+ ).toBeTruthy();
+ });
+
+ it('should handle corrupted mods.json file gracefully', async () => {
+ expect.hasAssertions();
+
+ // Mock that install is valid and mod is a directory
+ const { isValidInstall } = await import('./isValidInstall');
+ vi.mocked(isValidInstall).mockResolvedValue(true);
+
+ vi.spyOn(fs, 'statSync').mockReturnValue({
+ isDirectory: () => true,
+ } as fs.Stats);
+
+ // Mock corrupted file that causes JSON parse error
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockReturnValue('invalid json{');
+
+ const writeFileSyncSpy = vi
+ .spyOn(fs, 'writeFileSync')
+ .mockImplementation(() => {
+ // Mock writeFileSync to avoid actual file writes
+ });
+
+ // Mock getFileChangesToApplyMod to return empty changes
+ const { getFileChangesToApplyMod } = await import(
+ './getFileChangesToApplyMod'
+ );
+ vi.mocked(getFileChangesToApplyMod).mockResolvedValue([]);
+
+ await applyModsToInstallWithMerge(mockInstallDir, ['testMod']);
+
+ // Should still write new format despite corrupted existing file
+ const writtenData = writeFileSyncSpy.mock.calls[0][1] as string;
+ const parsedData = JSON.parse(writtenData);
+
+ expect(parsedData).toHaveProperty('appliedMods');
+ expect(parsedData.appliedMods).toHaveLength(1);
+ expect(parsedData.appliedMods[0]).toHaveProperty('name', 'testMod');
+ });
+
+ it('should not add duplicate mods to the tracking file', async () => {
+ expect.hasAssertions();
+
+ // Mock that install is valid and mod is a directory
+ const { isValidInstall } = await import('./isValidInstall');
+ vi.mocked(isValidInstall).mockResolvedValue(true);
+
+ vi.spyOn(fs, 'statSync').mockReturnValue({
+ isDirectory: () => true,
+ } as fs.Stats);
+
+ // Mock existing file with the mod we're trying to add
+ const existingData = {
+ appliedMods: [
+ { appliedDate: '2023-01-01T00:00:00.000Z', name: 'testMod' },
+ ],
+ };
+
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockReturnValue(
+ JSON.stringify(existingData)
+ );
+
+ const writeFileSyncSpy = vi
+ .spyOn(fs, 'writeFileSync')
+ .mockImplementation(() => {
+ // Mock writeFileSync to avoid actual file writes
+ });
+
+ // Mock getFileChangesToApplyMod to return empty changes
+ const { getFileChangesToApplyMod } = await import(
+ './getFileChangesToApplyMod'
+ );
+ vi.mocked(getFileChangesToApplyMod).mockResolvedValue([]);
+
+ await applyModsToInstallWithMerge(mockInstallDir, ['testMod']);
+
+ // Should not add duplicate
+ const writtenData = writeFileSyncSpy.mock.calls[0][1] as string;
+ const parsedData = JSON.parse(writtenData);
+
+ expect(parsedData.appliedMods).toHaveLength(1);
+ expect(parsedData.appliedMods[0]).toHaveProperty('name', 'testMod');
+ });
+
+ describe('round-trip compatibility tests', () => {
+ it('should maintain consistency between updateModsTrackingFile write and getAppliedMods read', async () => {
+ expect.hasAssertions();
+
+ // Mock that install is valid and mod is a directory
+ const { isValidInstall } = await import('./isValidInstall');
+ vi.mocked(isValidInstall).mockResolvedValue(true);
+
+ vi.spyOn(fs, 'statSync').mockReturnValue({
+ isDirectory: () => true,
+ } as fs.Stats);
+
+ // Mock no existing file
+ vi.mocked(fs.existsSync).mockReturnValue(false);
+
+ let writtenContent = '';
+ vi.spyOn(fs, 'writeFileSync').mockImplementation((_, content) => {
+ writtenContent = content as string;
+ });
+
+ // Mock getFileChangesToApplyMod to return empty changes
+ const { getFileChangesToApplyMod } = await import(
+ './getFileChangesToApplyMod'
+ );
+ vi.mocked(getFileChangesToApplyMod).mockResolvedValue([]);
+
+ // Apply mods (this will call updateModsTrackingFile internally)
+ await applyModsToInstallWithMerge(mockInstallDir, ['mod1', 'mod2']);
+
+ // Now simulate reading the file that was written
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.readFileSync).mockReturnValue(writtenContent);
+
+ const result = getAppliedMods(mockInstallDir);
+
+ expect(result).toStrictEqual(['mod1', 'mod2']);
+ });
+ });
+});
diff --git a/src/electron/main.ts b/src/electron/main.ts
index 84718cf..421c756 100644
--- a/src/electron/main.ts
+++ b/src/electron/main.ts
@@ -8,6 +8,7 @@ import { addToInstallDirs } from './file/addToInstallDirs';
import { applyModsToInstall } from './file/applyModsToInstall';
import { copyFileToModDir } from './file/copyFileToModDir';
import { deleteBackup } from './file/deleteBackup';
+import { enrichInstallDirsWithCtpVersion } from './file/enrichInstallDirsWithCtpVersion';
import { getAppliedMods } from './file/getAppliedMods';
import { getCtp2ExecutablePath } from './file/getGameExecutablePath';
import { getInstallDirs } from './file/getInstallDirs';
@@ -123,6 +124,10 @@ app.whenReady().then(() => {
deleteBackup(backupPath)
);
+ ipcMain.handle('file:enrichInstallDirsWithCtpVersion', (_, installDirs) =>
+ enrichInstallDirsWithCtpVersion(installDirs)
+ );
+
ipcMain.handle('file:getCtp2ExecutablePath', (_, installDir) =>
getCtp2ExecutablePath(installDir)
);
diff --git a/src/electron/preload.ts b/src/electron/preload.ts
index dab6ee1..1b8f18a 100644
--- a/src/electron/preload.ts
+++ b/src/electron/preload.ts
@@ -18,6 +18,10 @@ contextBridge.exposeInMainWorld('api', {
deleteBackup: (_: Event, backupPath: string) =>
ipcRenderer.invoke('file:deleteBackup', backupPath),
+ // Enriches installation directories with CTP version information
+ enrichInstallDirsWithCtpVersion: (_: Event, installDirs: unknown[]) =>
+ ipcRenderer.invoke('file:enrichInstallDirsWithCtpVersion', installDirs),
+
// Gets the list of mods applied to an installation
getAppliedMods: (_: Event, installDir: string) =>
ipcRenderer.invoke('file:getAppliedMods', installDir),
diff --git a/src/integration/modApplication.integration.test.ts b/src/integration/modApplication.integration.test.ts
new file mode 100644
index 0000000..2cac4c9
--- /dev/null
+++ b/src/integration/modApplication.integration.test.ts
@@ -0,0 +1,39 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { applyModsToInstall } from '../electron/file/applyModsToInstall';
+
+// Mock electron module
+vi.mock('electron', () => ({
+ app: {
+ getName: vi.fn().mockReturnValue('ctp-mod-manager'),
+ getPath: vi.fn().mockReturnValue('/mock/path'),
+ },
+}));
+
+describe('mod application integration tests', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should demonstrate error propagation from backend to potential UI layer', async () => {
+ expect.assertions(1);
+
+ // This test demonstrates that errors are now properly thrown
+ // and can be caught by the UI layer, instead of being silently logged
+ await expect(
+ applyModsToInstall('/nonexistent/path', ['testMod'])
+ ).rejects.toBeInstanceOf(Error);
+ });
+
+ it('should show that errors contain helpful information for users', async () => {
+ expect.assertions(1);
+
+ await expect(
+ applyModsToInstall('/invalid/installation', ['testMod'])
+ ).rejects.toThrow('ENOENT: no such file or directory');
+ });
+});