diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 547e79e..1785915 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -178,9 +178,11 @@ module.exports = { 'functional/no-return-void': 'off', 'functional/no-throw-statements': 'off', + 'jsdoc/check-tag-names': 'off', 'no-undef': 'off', 'no-unused-vars': 'off', 'vitest/max-expects': 'off', + 'vitest/no-hooks': 'off', }, }, diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..50a1480 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,27 @@ +**CRITICAL: Always output the results of any commands you run to temp.log (in the root directory), then read that to get the results of the previous command. This ensures that you can read it - there is a bug at the moment that will prevent you from reading commands directly. Use tee when doing this so I can see the command running without having to open temp.log. You should NOT have to run "echo "Starting header test creation" | tee temp.log" or similar - only do it for test runs and other important commands. Echo should almost never be present.** + +STEPS TO FOLLOW: + +Try your best to follow the linting rules the first time, that way you will not have to come back to change the code later (see .eslintrc.cjs in the root). + +Before starting ANY new features or editing existing features, create tests for the feature, run the tests you created (they should fail) and use test-driven development (red-green testing) to ensure the feature works according to the spec you filled out. Mock and stub anything that needs to be mocked / stubbed, but don't overdo it. + +Instead of writing the whole files at once, write the files in incremental steps - writing a function at a time (so you don't corrupt it). + +Once each step is completed, run "npm run lint" and fix any issues (warnings OR errors) that pop up. Additionally, run the tests with "npm test -- run" and fix any test failures that remain. You are not allowed to change any linting rules, styling rules or any testing rules. + +If a single React component has more than 5 useState / state management calls, convert it to use a React reducer as a part of your task. + +TOOLS TO USE: + +A pre-install hook will prevent you from ever using npm. Use npm, not pnpm (pnpm has issues with Electron). + +Use Vitest and Playwright for tests. + +Use eslint for linting. You can use the ESLint MCP server to check the current ESLint status. + +You can add packages (especially if they make the job significantly easier). Make sure that if you want to add a package, you tell me the reasoning first. + +ALWAYS run tests without watch mode so I don't have to cancel / quit the runs for you. + +The "diffing" portion of the repository (applyModsToInstallWithMerge, etc.) are legacy and must be kept intact, but should not be considered for refactoring or actual use. diff --git a/.gitignore b/.gitignore index ce4097d..fb63690 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,7 @@ out/ .vite/* -debug \ No newline at end of file +debug +examples + +temp.log \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fa1ef79..65fe9b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ There are a few different steps to get started with this repo. To get started, you will need: -- `Node` and `npm` installed (nvm is highly recommended - the repo is set up so that you can simply run `nvm use` in the root to install the correct versions automatically) +- `Node` and `npm` installed (nvm is highly recommended - the repo is set up so that you can simply run `nvm use` in the root to install the correct versions automatically) From here, you can start the dev server with: @@ -18,6 +18,6 @@ Code should be formatted with [Prettier](https://prettier.io/). The recommended Relevant unit or integration tests are always appreciated, but not necessarily required (yet). Eventually, the plan is 80% test coverage, but this project has ballooned quite a lot. -Submit all code via a pull-request on GitHub. I understand it may be a bit hypocritical (given I have just pushed to main since the beginning), but I was the sole contributor for the two years before this project was open-sourced, so there was no need to use PRs. That said, I am going to use PRs for all future contributions as that is the expectation going forward. +Submit all code via a pull-request on GitHub. I understand it may be a bit hypocritical (given I have just pushed to main since the beginning), but I was the sole contributor for the two years before this project was open-sourced, so there was no need to use PRs. That said, I am going to use PRs for all future contributions as that is the expectation going forward. -Before you submit your PR, ensure that there are NO warnings or errors with ESLint by running `npm run lint`. After that, make sure all tests pass with `npm test`. If both of these work, feel free to submit the PR! \ No newline at end of file +Before you submit your PR, ensure that there are NO warnings or errors with ESLint by running `npm run lint`. After that, make sure all tests pass with `npm test`. If both of these work, feel free to submit the PR! diff --git a/README.md b/README.md index 4d4b98a..8aa589c 100644 --- a/README.md +++ b/README.md @@ -31,18 +31,20 @@ Most mods can just be applied in a single click, including scenario mods. Incomp ![image](https://github.com/user-attachments/assets/dfe0ecf7-fd5d-48a5-aa7a-739cfc19fc50) - ## Change Your Theme You can change your themes with a click of a button to any of DaisyUI's wonderful themes. These include light modes, dark modes and most modes in-between! ### Light: + ![image](https://github.com/user-attachments/assets/438322aa-7ed9-4efd-bd12-6aaf365d9ebb) ### Dark: + ![image](https://github.com/user-attachments/assets/64e5ad90-541a-46ef-beae-2830bd1a568d) -### Halloween: +### Halloween: + ![image](https://github.com/user-attachments/assets/8befd4a6-c3ad-44a3-a752-4afcb2717006) ... and many more! diff --git a/index.html b/index.html index 3620f78..0c38611 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@ - Call to Power Mod Manager (Alpha) + Call to Power Mod Manager (Beta) diff --git a/nguid.ctp b/nguid.ctp new file mode 100644 index 0000000..3f9f96e --- /dev/null +++ b/nguid.ctp @@ -0,0 +1 @@ +ͱż]GȘAș*7_Ö” \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 24bb621..1f7cb33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,6 +76,7 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.37.4", + "jsdom": "^26.1.0", "prettier": "^3.5.0", "prettier-eslint": "^16.3.0", "prettier-plugin-tailwindcss": "^0.6.11", @@ -118,6 +119,25 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, "node_modules/@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -420,6 +440,116 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", + "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", + "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/color-helpers": "^5.0.2", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@electron-forge/cli": { "version": "6.4.2", "resolved": "https://registry.npmjs.org/@electron-forge/cli/-/cli-6.4.2.tgz", @@ -6029,6 +6159,19 @@ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -6048,6 +6191,53 @@ "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -6128,6 +6318,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -6939,6 +7135,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-cmd": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/env-cmd/-/env-cmd-10.1.0.tgz", @@ -9477,6 +9685,18 @@ "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==" }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -10167,6 +10387,12 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -10606,6 +10832,136 @@ "node": ">=12.0.0" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -11774,6 +12130,12 @@ "node": ">=4" } }, + "node_modules/nwsapi": { + "version": "2.2.21", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.21.tgz", + "integrity": "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA==", + "dev": true + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -12174,6 +12536,18 @@ "node": ">=0.10.0" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -13466,6 +13840,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true + }, "node_modules/rspack-resolver": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/rspack-resolver/-/rspack-resolver-1.1.2.tgz", @@ -13591,6 +13971,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "devOptional": true }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -14334,6 +14726,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, "node_modules/synckit": { "version": "0.9.2", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.2.tgz", @@ -14555,6 +14953,24 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true + }, "node_modules/tmp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", @@ -14613,6 +15029,18 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -15526,6 +15954,18 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wait-on": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz", @@ -15559,6 +15999,39 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "dev": true }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -15730,6 +16203,15 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -15738,6 +16220,12 @@ "node": ">=8.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 8a5d597..5452482 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "lint-fix": "eslint . --fix", "package": "electron-forge package", "make": "electron-forge make", - "format": "npx prettier --write ." + "format": "npx prettier --config .prettierrc --write ." }, "keywords": [], "license": "LGPL-2.1-or-later", @@ -43,6 +43,7 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.37.4", + "jsdom": "^26.1.0", "prettier": "^3.5.0", "prettier-eslint": "^16.3.0", "prettier-plugin-tailwindcss": "^0.6.11", diff --git a/src/App.tsx b/src/App.tsx index 07c22a8..947c4f1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { TrashIcon } from './components/icons/trash'; import { InstallationPathText } from './components/InstallationPathText'; import { InstallDirTable } from './components/InstallDirTable'; import { Modal } from './components/Modal'; +import { ReleaseNotesModal } from './components/ReleaseNotesModal'; import { Settings as SettingsMenu } from './components/Settings'; import { AUTO_DETECT_INSTALL_TEXT } from './constants'; @@ -41,6 +42,10 @@ export type ElectronWindow = typeof globalThis & ipcCommand: 'file:deleteBackup', backupPath: string ) => Promise; + enrichInstallDirsWithCtpVersion: ( + ipcCommand: 'file:enrichInstallDirsWithCtpVersion', + installDirs: ReadonlyDeep + ) => Promise; getAppliedMods: ( ipcCommand: 'file:getAppliedMods', installDir: string @@ -114,6 +119,7 @@ export type ElectronWindow = typeof globalThis & }; export type InstallDirectory = { + ctpVersion?: 'CTP1' | 'CTP2' | 'Unknown'; directory: string; installationType: 'gog' | 'steam'; isWSL?: boolean; @@ -136,7 +142,15 @@ export const App: FC = (): React.ReactElement => { os: 'win32' as const, })); - dispatch({ payload: dirs, type: 'SET_INSTALL_DIRS' }); + // Enrich directories with CTP version information + const enrichedDirs = await ( + window as ElectronWindow + ).api.enrichInstallDirsWithCtpVersion( + 'file:enrichInstallDirsWithCtpVersion', + dirs + ); + + dispatch({ payload: enrichedDirs, type: 'SET_INSTALL_DIRS' }); if (dirsFromFile.length === 0) { dispatch({ payload: true, type: 'SET_INSTALL_DIR_MODAL_OPEN' }); @@ -206,6 +220,20 @@ export const App: FC = (): React.ReactElement => { dispatch({ payload: false, type: 'SET_SHOW_ALPHA_WARNING' }); }, [dispatch]); + const handleReleaseNotesClose = useCallback( + (dontShowAgain: boolean): void => { + if (dontShowAgain) { + localStorage.setItem('releaseNotesAcknowledged-0.7.0', 'true'); + dispatch({ + payload: true, + type: 'SET_DONT_SHOW_RELEASE_NOTES_AGAIN', + }); + } + dispatch({ payload: false, type: 'SET_SHOW_RELEASE_NOTES' }); + }, + [dispatch] + ); + const loadModFileNames = useCallback(async (): Promise => { dispatch({ payload: true, type: 'SET_LOADING_MODS' }); try { @@ -281,6 +309,18 @@ export const App: FC = (): React.ReactElement => { dispatch({ payload: false, type: 'SET_SHOW_ALPHA_WARNING' }); } + // Check if the user has already acknowledged the release notes for this version + const releaseNotesAcknowledged = localStorage.getItem( + 'releaseNotesAcknowledged-0.7.0' + ); + if (releaseNotesAcknowledged) { + dispatch({ payload: false, type: 'SET_SHOW_RELEASE_NOTES' }); + dispatch({ + payload: true, + type: 'SET_DONT_SHOW_RELEASE_NOTES_AGAIN', + }); + } + loadModFileNames(); loadInstallDirs(); themeChange(false); @@ -298,7 +338,7 @@ export const App: FC = (): React.ReactElement => { text: 'I Understand and Accept', }, ]} - modalName="Alpha Software Warning" + modalName="Beta Software Warning" onClose={(): void => { // Don't allow closing without explicit acceptance }} @@ -321,9 +361,9 @@ export const App: FC = (): React.ReactElement => { />
-

Alpha Software Warning

+

Beta Software Warning

- This is alpha software and is still under active + This is beta software and is still under active development. You may encounter bugs, crashes, or data loss while using this application.
@@ -344,6 +384,14 @@ export const App: FC = (): React.ReactElement => {

+ {/* Release Notes Modal */} + + {/* Add the DeleteBackupModal */} { // Refresh installation directories to update applied mods await loadInstallDirs(); } + } catch (err) { + // Handle mod application errors and show them to the user + const errorMessage = + err instanceof + Error + ? err.message + : 'An unknown error occurred while applying mods'; + + dispatch({ + payload: `Failed to apply mods: ${errorMessage}`, + type: 'SET_ERROR', + }); } finally { dispatch({ payload: false, diff --git a/src/app-reducer.ts b/src/app-reducer.ts index 410ff27..de11b8c 100644 --- a/src/app-reducer.ts +++ b/src/app-reducer.ts @@ -10,11 +10,13 @@ export type AppAction = | { payload: boolean; type: 'SET_BACKUP_NAME_MODAL_OPEN' } | { payload: boolean; type: 'SET_BACKUP_RESTORE_OPEN' } | { payload: boolean; type: 'SET_DELETE_BACKUP_OPEN' } + | { payload: boolean; type: 'SET_DONT_SHOW_RELEASE_NOTES_AGAIN' } | { payload: boolean; type: 'SET_INSTALL_DIR_MODAL_OPEN' } | { payload: boolean; type: 'SET_LOADING_DIRS' } | { payload: boolean; type: 'SET_LOADING_MODS' } | { payload: boolean; type: 'SET_SETTINGS_OPEN' } | { payload: boolean; type: 'SET_SHOW_ALPHA_WARNING' } + | { payload: boolean; type: 'SET_SHOW_RELEASE_NOTES' } | { payload: InstallDirectory[]; type: 'ADD_TO_INSTALL_DIRS' } | { payload: InstallDirectory[]; type: 'SET_INSTALL_DIRS' } | { payload: number; type: 'TOGGLE_SELECTED_INSTALLATION' } @@ -41,6 +43,7 @@ export interface AppState { deleteBackupOpen: boolean; deletingBackupDir: string; dirBeingModified: string; + dontShowReleaseNotesAgain: boolean; error: string | undefined; installDirModalOpen: boolean; installDirs: InstallDirectory[]; @@ -51,6 +54,7 @@ export interface AppState { selectedInstallations: number[]; settingsOpen: boolean; showAlphaWarning: boolean; + showReleaseNotes: boolean; } // Define initial state @@ -64,6 +68,7 @@ export const initialState: AppState = { deleteBackupOpen: false, deletingBackupDir: '', dirBeingModified: '', + dontShowReleaseNotesAgain: false, error: undefined, installDirModalOpen: false, installDirs: [], @@ -74,6 +79,7 @@ export const initialState: AppState = { selectedInstallations: [], settingsOpen: false, showAlphaWarning: true, + showReleaseNotes: true, }; // Create the reducer function @@ -131,6 +137,9 @@ export const appReducer = ( case 'SET_DIR_BEING_MODIFIED': draft.dirBeingModified = action.payload; break; + case 'SET_DONT_SHOW_RELEASE_NOTES_AGAIN': + draft.dontShowReleaseNotesAgain = action.payload; + break; case 'SET_ERROR': draft.error = action.payload; break; @@ -158,6 +167,9 @@ export const appReducer = ( case 'SET_SHOW_ALPHA_WARNING': draft.showAlphaWarning = action.payload; break; + case 'SET_SHOW_RELEASE_NOTES': + draft.showReleaseNotes = action.payload; + break; case 'TOGGLE_CHECKED_MOD': const modIndex = draft.checkedMods.indexOf(action.payload); if (modIndex === -1) { diff --git a/src/components/App.errorHandling.test.tsx b/src/components/App.errorHandling.test.tsx new file mode 100644 index 0000000..7772097 --- /dev/null +++ b/src/components/App.errorHandling.test.tsx @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { appReducer, initialState } from '../app-reducer'; + +describe('error handling logic', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should handle SET_ERROR action to set error message', () => { + expect.hasAssertions(); + const state = { ...initialState }; + const errorMessage = 'Failed to apply mods: Permission denied'; + const action = { payload: errorMessage, type: 'SET_ERROR' as const }; + + appReducer(state, action); + + expect(state.error).toBe(errorMessage); + }); + + it('should handle SET_ERROR action to clear error message', () => { + expect.hasAssertions(); + const state = { ...initialState, error: 'Some error' }; + const action = { + payload: undefined as string | undefined, + type: 'SET_ERROR' as const, + }; + + appReducer(state, action); + + expect(state.error).toBeUndefined(); + }); + + it('should handle SET_APPLYING_MODS action', () => { + expect.hasAssertions(); + const state = { ...initialState }; + const action = { payload: true, type: 'SET_APPLYING_MODS' as const }; + + appReducer(state, action); + + expect(state.applyingMods).toBeTruthy(); + }); + + it('should handle error state transitions during mod application', () => { + expect.hasAssertions(); + const state = { ...initialState }; + + // Start applying mods + appReducer(state, { payload: true, type: 'SET_APPLYING_MODS' }); + expect(state.applyingMods).toBeTruthy(); + + // Set an error + const errorMessage = 'Failed to apply mod: Invalid directory'; + appReducer(state, { payload: errorMessage, type: 'SET_ERROR' }); + expect(state.error).toBe(errorMessage); + + // Stop applying mods + appReducer(state, { payload: false, type: 'SET_APPLYING_MODS' }); + expect(state.applyingMods).toBeFalsy(); + expect(state.error).toBe(errorMessage); // Error should persist + }); +}); diff --git a/src/components/App.releaseNotes.test.tsx b/src/components/App.releaseNotes.test.tsx new file mode 100644 index 0000000..8c07d8d --- /dev/null +++ b/src/components/App.releaseNotes.test.tsx @@ -0,0 +1,47 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest'; + +import { appReducer, initialState } from '../app-reducer'; + +describe('app Release Notes Modal', () => { + it('should show release notes modal by default', () => { + expect.hasAssertions(); + expect(initialState.showReleaseNotes).toBeTruthy(); + }); + + it('should handle SET_SHOW_RELEASE_NOTES action', () => { + expect.hasAssertions(); + const state = { ...initialState }; + const action = { + payload: false, + type: 'SET_SHOW_RELEASE_NOTES' as const, + }; + + appReducer(state, action); + + expect(state.showReleaseNotes).toBeFalsy(); + }); + + it('should handle SET_DONT_SHOW_RELEASE_NOTES_AGAIN action', () => { + expect.hasAssertions(); + const state = { ...initialState }; + const action = { + payload: true, + type: 'SET_DONT_SHOW_RELEASE_NOTES_AGAIN' as const, + }; + + appReducer(state, action); + + expect(state.dontShowReleaseNotesAgain).toBeTruthy(); + }); + + it('should not show release notes if user has opted out', () => { + expect.hasAssertions(); + const state = { ...initialState, dontShowReleaseNotesAgain: true }; + expect( + state.showReleaseNotes && !state.dontShowReleaseNotesAgain + ).toBeFalsy(); + }); +}); diff --git a/src/components/InstallDirTable.tsx b/src/components/InstallDirTable.tsx index 28261ac..390bed0 100644 --- a/src/components/InstallDirTable.tsx +++ b/src/components/InstallDirTable.tsx @@ -237,10 +237,11 @@ export const InstallDirTable: FC = ({ - - - - + + + + + @@ -285,6 +286,22 @@ export const InstallDirTable: FC = ({ {installDir.installationType.toUpperCase()} +
SelectInstallationTypeApplied ModsSelectInstallationTypeCTP VersionApplied Mods Actions
+ + {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 + ) + )} + +
  • + ))} +
+
+ +
+ +
+ +
+
+
+ ); +}; 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'); + }); +});