diff --git a/jest.config.js b/jest.config.js index 005f6f994..eb5cba75b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,7 +5,8 @@ module.exports = { globals: { 'ts-jest': {} }, - + testTimeout: 35000, + setupFilesAfterEnv: ['./jest.setup.ts'], collectCoverageFrom: ['**/*.{js,jsx,ts,tsx}', '!**/*.config.js'], coverageDirectory: '/coverage', coveragePathIgnorePatterns: [ diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 000000000..31775ac4d --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1 @@ +jest.setTimeout(35000) diff --git a/package.json b/package.json index 3c7eb4f79..f9e35086d 100644 --- a/package.json +++ b/package.json @@ -324,7 +324,7 @@ "@types/express": "^4.17.21", "@types/i18next-fs-backend": "^1.1.5", "@types/ini": "^1.3.34", - "@types/jest": "^29.5.12", + "@types/jest": "^29.5.14", "@types/jsdom": "^20.0.1", "@types/mime": "^3.0.2", "@types/node": "^20.16.2", @@ -334,6 +334,7 @@ "@types/react-blockies": "^1.4.1", "@types/react-dom": "^18.0.8", "@types/react-router-dom": "^5.3.3", + "@types/rimraf": "^4.0.5", "@types/tmp": "^0.2.6", "@types/ws": "^8.5.12", "@typescript-eslint/eslint-plugin": "^6.21.0", @@ -357,9 +358,10 @@ "prettier": "^2.8.8", "pretty-quick": "^4.0.0", "react-markdown": "^9.0.1", + "rimraf": "^6.0.1", "sass": "^1.55.0", "tmp": "^0.2.3", - "ts-jest": "^29.1.1", + "ts-jest": "^29.2.5", "type-fest": "^3.2.0", "typescript": "5.3.3", "vite": "^5.4.0", diff --git a/src/backend/__tests__/utils.test.ts b/src/backend/__tests__/utils.test.ts index 93059febc..05142d30a 100644 --- a/src/backend/__tests__/utils.test.ts +++ b/src/backend/__tests__/utils.test.ts @@ -1,5 +1,11 @@ import * as utils from '../utils' -import { getExecutableAndArgs } from '../utils' +import { getExecutableAndArgs, copyRecursiveAsync } from '../utils' +import { mkdir, writeFile, symlink } from 'fs/promises' +import { join } from 'path' +import { chmod, existsSync } from 'fs' +import { rimraf } from 'rimraf' +import os from 'os' +import * as fs from 'fs' jest.mock('electron') jest.mock('../logger/logger') @@ -261,4 +267,77 @@ describe('backend/utils.ts', () => { expect(getExecutableAndArgs(input)).toEqual(expected) }) }) + + describe('copyRecursiveAsync', () => { + const testDir = join(os.tmpdir(), `test-copy-${Date.now()}`) + const sourceDir = join(testDir, 'source') + const destDir = join(testDir, 'dest') + + beforeEach(async () => { + // Setup test directories + await mkdir(sourceDir, { recursive: true }) + await mkdir(destDir, { recursive: true }) + }) + + afterEach(async () => { + // Cleanup test directories + await rimraf(testDir) + }) + + it('should copy a single file', async () => { + const testFile = join(sourceDir, 'test.txt') + const destFile = join(destDir, 'test.txt') + await writeFile(testFile, 'test content') + + await copyRecursiveAsync(testFile, destFile) + + expect(existsSync(destFile)).toBe(true) + }) + + it('should copy a directory recursively', async () => { + const subDir = join(sourceDir, 'subdir') + const testFile = join(subDir, 'test.txt') + await mkdir(subDir, { recursive: true }) + await writeFile(testFile, 'test content') + + await copyRecursiveAsync(sourceDir, join(destDir, 'source')) + + expect(existsSync(join(destDir, 'source/subdir/test.txt'))).toBe(true) + }) + + it('should skip symbolic links', async () => { + const testFile = join(sourceDir, 'test.txt') + const linkFile = join(sourceDir, 'link.txt') + await writeFile(testFile, 'test content') + await symlink(testFile, linkFile) + + await copyRecursiveAsync(linkFile, join(destDir, 'link.txt')) + + expect(existsSync(join(destDir, 'link.txt'))).toBe(false) + }) + + it('should throw on timeout', async () => { + const COPY_TIMEOUT_MS = 30000 + const testFile = join(sourceDir, 'test.txt') + await writeFile(testFile, 'test content') + + // Mock the copyFile function to simulate a slow operation + const mockCopyFile = jest + .spyOn(fs.promises, 'copyFile') + .mockImplementation(async () => { + return new Promise((resolve) => { + setTimeout(resolve, COPY_TIMEOUT_MS + 1000) // Wait longer than timeout + }) + }) + + const destFile = join(destDir, 'test.txt') + + await expect(copyRecursiveAsync(testFile, destFile)).rejects.toThrow( + 'Timeout' + ) + + // Restore original implementation + mockCopyFile.mockRestore() + }) + }) }) diff --git a/src/backend/ipcHandlers/mods.ts b/src/backend/ipcHandlers/mods.ts index 12d5f6bb3..c7ff7bfbd 100644 --- a/src/backend/ipcHandlers/mods.ts +++ b/src/backend/ipcHandlers/mods.ts @@ -1,3 +1,4 @@ +import { captureException } from '@sentry/electron' import { notify, showDialogBoxModalAuto } from 'backend/dialog/dialog' import { cancelQueueExtraction } from 'backend/downloadmanager/downloadqueue' import { LogPrefix, logDebug, logError, logInfo } from 'backend/logger/logger' @@ -233,7 +234,15 @@ export async function prepareBaseGameForModding({ readdirSync(extractedFolderFullPath).forEach(async (file) => { const srcPath = path.join(extractedFolderFullPath, file) const destPath = path.join(dirPath, file) - await copyRecursiveAsync(srcPath, destPath) + try { + await copyRecursiveAsync(srcPath, destPath) + } catch (error) { + const errorMessage = `Error copying ${srcPath} to ${destPath} ${error}` + logError(errorMessage, LogPrefix.HyperPlay) + extractService.emit('error', new Error(errorMessage)) + captureException(error) + throw new Error(errorMessage) + } }) // remove the extracted folder diff --git a/src/backend/storeManagers/hyperplay/games.ts b/src/backend/storeManagers/hyperplay/games.ts index 4e9bd761b..cc12a6b44 100644 --- a/src/backend/storeManagers/hyperplay/games.ts +++ b/src/backend/storeManagers/hyperplay/games.ts @@ -810,6 +810,7 @@ export async function install( const gameInfo = getGameInfo(appName) const { title, account_name } = gameInfo const isMarketWars = account_name === 'marketwars' + if (isMarketWars && modOptions?.zipFilePath) { try { await prepareBaseGameForModding({ @@ -818,6 +819,7 @@ export async function install( installPath: dirpath }) } catch (error) { + callAbortController(appName) return { status: 'error' } } } diff --git a/src/backend/storeManagers/hyperplay/utils.ts b/src/backend/storeManagers/hyperplay/utils.ts index 7a163f265..818009c9e 100644 --- a/src/backend/storeManagers/hyperplay/utils.ts +++ b/src/backend/storeManagers/hyperplay/utils.ts @@ -15,13 +15,14 @@ import { qaToken, valistListingsApiUrl } from 'backend/constants' -import { getGameInfo } from './games' +import { getGameInfo, getSettings } from './games' import { LogPrefix, logError, logInfo } from 'backend/logger/logger' import { join } from 'path' import { existsSync } from 'graceful-fs' import { ProjectMetaInterface } from '@valist/sdk/dist/typesShared' import getPartitionCookies from 'backend/utils/get_partition_cookies' import { DEV_PORTAL_URL } from 'common/constants' +import { runWineCommand } from 'backend/launcher' export async function getHyperPlayStoreRelease( appName: string @@ -386,14 +387,14 @@ export async function getEpicListingUrl(projectId: string): Promise { } export const runModPatcher = async (appName: string) => { + // game_folder/client-patcher patch.exe -m patch/manifest.json const installPath = getGameInfo(appName)?.install.install_path if (!installPath) { logError(`Cannot find install path for ${appName}`, LogPrefix.HyperPlay) return } - const patcherBinary = isWindows ? 'client-patcher.exe' : 'client-patcher' - const patcher = join(installPath, patcherBinary) + const patcher = join(installPath, 'client-patcher.exe') const manifest = join(installPath, 'patch', 'manifest.json') logInfo( @@ -406,14 +407,15 @@ export const runModPatcher = async (appName: string) => { } try { - const { stderr, stdout } = await spawnAsync( - patcherBinary, - ['patch', '-m', 'patch/manifest.json'], - { cwd: installPath } - ) - logInfo(['Patch Applied', stdout], LogPrefix.HyperPlay) - if (stderr) { - logError(stderr, LogPrefix.HyperPlay) + if (!isWindows) { + const gameSettings = await getSettings(appName) + runWineCommand({ + gameSettings, + commandParts: [patcher, 'patch', '-m', manifest], + wait: true + }) + } else { + await spawnAsync(patcher, ['patch', '-m', manifest]) } } catch (error) { throw new Error(`Error running patcher: ${error}`) diff --git a/src/backend/utils.ts b/src/backend/utils.ts index 8e7eac742..befa492d1 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -80,7 +80,7 @@ import { deviceNameCache, vendorNameCache } from './utils/systeminfo/gpu/pci_ids' -import { copyFile, mkdir, readdir, stat } from 'fs/promises' +import { copyFile, lstat, mkdir, readdir } from 'fs/promises' import { GameConfig } from './game_config' const execAsync = promisify(exec) @@ -1306,9 +1306,16 @@ export const writeConfig = (appName: string, config: Partial) => { } } +const COPY_TIMEOUT_MS = 30000 // wait time before throwing a timeout error export async function copyRecursiveAsync(src: string, dest: string) { - const exists = (await stat(src)).isDirectory() - if (exists) { + const stats = await lstat(src) + if (stats.isSymbolicLink()) { + return // Skip symbolic links + } + + const isDirectory = stats.isDirectory() + + if (isDirectory) { await mkdir(dest, { recursive: true }) const files = await readdir(src) await Promise.all( @@ -1319,6 +1326,13 @@ export async function copyRecursiveAsync(src: string, dest: string) { }) ) } else { - await copyFile(src, dest) + await Promise.race([ + copyFile(src, dest), + wait(COPY_TIMEOUT_MS).then(() => { + throw new Error( + `Timeout (${COPY_TIMEOUT_MS}ms) copying ${src} to ${dest}` + ) + }) + ]) } } diff --git a/yarn.lock b/yarn.lock index 016452fe3..6982f5ec2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1344,7 +1344,7 @@ dependencies: bignumber.js "^9.1.2" -"@hyperplay/utils@^0.3.4": +"@hyperplay/utils@^0.3.3", "@hyperplay/utils@^0.3.4": version "0.3.4" resolved "https://registry.yarnpkg.com/@hyperplay/utils/-/utils-0.3.4.tgz#71d4abe910ec8550c865418118eb1c07df3b399d" integrity sha512-ndkDnp+iV9BlLCKpbq0XVuuc/ORzaZ+69wFvVvAftdU6mbwbk4EfxAwTOpmXWkLaiGp0MZaybYMa5zYZkxkW5w== @@ -3435,10 +3435,10 @@ dependencies: "@types/istanbul-lib-report" "*" -"@types/jest@^29.5.12": - version "29.5.12" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.12.tgz#7f7dc6eb4cf246d2474ed78744b05d06ce025544" - integrity sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw== +"@types/jest@^29.5.14": + version "29.5.14" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.14.tgz#2b910912fa1d6856cadcd0c1f95af7df1d6049e5" + integrity sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ== dependencies: expect "^29.0.0" pretty-format "^29.0.0" @@ -3608,6 +3608,13 @@ dependencies: "@types/node" "*" +"@types/rimraf@^4.0.5": + version "4.0.5" + resolved "https://registry.yarnpkg.com/@types/rimraf/-/rimraf-4.0.5.tgz#7a59be11605c22ea3959c21ff8b28b9df1bae1b2" + integrity sha512-DTCZoIQotB2SUJnYgrEx43cQIUYOlNZz0AZPbKU4PSLYTUdML5Gox0++z4F9kQocxStrCmRNhi4x5x/UlwtKUA== + dependencies: + rimraf "*" + "@types/secp256k1@^4.0.6": version "4.0.6" resolved "https://registry.yarnpkg.com/@types/secp256k1/-/secp256k1-4.0.6.tgz#d60ba2349a51c2cbc5e816dcd831a42029d376bf" @@ -7897,6 +7904,18 @@ glob@^10.2.2, glob@^10.3.10: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" +glob@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-11.0.0.tgz#6031df0d7b65eaa1ccb9b29b5ced16cea658e77e" + integrity sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g== + dependencies: + foreground-child "^3.1.0" + jackspeak "^4.0.1" + minimatch "^10.0.0" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^2.0.0" + glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" @@ -8966,6 +8985,13 @@ jackspeak@^3.1.2: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" +jackspeak@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.0.2.tgz#11f9468a3730c6ff6f56823a820d7e3be9bef015" + integrity sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw== + dependencies: + "@isaacs/cliui" "^8.0.2" + jake@^10.8.5: version "10.9.2" resolved "https://registry.yarnpkg.com/jake/-/jake-10.9.2.tgz#6ae487e6a69afec3a5e167628996b59f35ae2b7f" @@ -9888,6 +9914,11 @@ lru-cache@^10.0.1, lru-cache@^10.2.0: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== +lru-cache@^11.0.0: + version "11.0.2" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.0.2.tgz#fbd8e7cf8211f5e7e5d91905c415a3f55755ca39" + integrity sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA== + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" @@ -10400,6 +10431,13 @@ minimatch@9.0.3: dependencies: brace-expansion "^2.0.1" +minimatch@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.0.1.tgz#ce0521856b453c86e25f2c4c0d03e6ff7ddc440b" + integrity sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ== + dependencies: + brace-expansion "^2.0.1" + minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -11193,6 +11231,14 @@ path-scurry@^1.11.1: lru-cache "^10.2.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" +path-scurry@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.0.tgz#9f052289f23ad8bf9397a2a0425e7b8615c58580" + integrity sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg== + dependencies: + lru-cache "^11.0.0" + minipass "^7.1.2" + path-to-regexp@0.1.10: version "0.1.10" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.10.tgz#67e9108c5c0551b9e5326064387de4763c4d5f8b" @@ -12131,6 +12177,14 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== +rimraf@*, rimraf@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-6.0.1.tgz#ffb8ad8844dd60332ab15f52bc104bc3ed71ea4e" + integrity sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A== + dependencies: + glob "^11.0.0" + package-json-from-dist "^1.0.0" + rimraf@^2.5.1, rimraf@^2.5.4, rimraf@^2.6.3: version "2.7.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" @@ -13272,7 +13326,7 @@ ts-api-utils@^1.0.1: resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz#4b490e27129f1e8e686b45cc4ab63714dc60eea1" integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ== -ts-jest@^29.1.1: +ts-jest@^29.2.5: version "29.2.5" resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.2.5.tgz#591a3c108e1f5ebd013d3152142cb5472b399d63" integrity sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==