diff --git a/jest.config.js b/jest.config.js index 005f6f9946..eb5cba75bf 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 0000000000..31775ac4da --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1 @@ +jest.setTimeout(35000) diff --git a/package.json b/package.json index 54c1ad064a..75e1bb1961 100644 --- a/package.json +++ b/package.json @@ -324,7 +324,7 @@ "@types/graceful-fs": "^4.1.9", "@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/pnpm-lock.yaml b/pnpm-lock.yaml index 806ef2309d..8a85bdc10a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -371,7 +371,7 @@ importers: specifier: ^1.3.34 version: 1.3.34 '@types/jest': - specifier: ^29.5.12 + specifier: ^29.5.14 version: 29.5.14 '@types/jsdom': specifier: ^20.0.1 @@ -400,6 +400,9 @@ importers: '@types/react-router-dom': specifier: ^5.3.3 version: 5.3.3 + '@types/rimraf': + specifier: ^4.0.5 + version: 4.0.5 '@types/tmp': specifier: ^0.2.6 version: 0.2.6 @@ -466,6 +469,9 @@ importers: pretty-quick: specifier: ^4.0.0 version: 4.0.0(prettier@2.8.8) + rimraf: + specifier: ^6.0.1 + version: 6.0.1 sass: specifier: ^1.55.0 version: 1.83.0 @@ -473,7 +479,7 @@ importers: specifier: ^0.2.3 version: 0.2.3 ts-jest: - specifier: ^29.1.1 + specifier: ^29.2.5 version: 29.2.5(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@20.17.10)(babel-plugin-macros@3.1.0))(typescript@5.3.3) type-fest: specifier: ^3.2.0 @@ -3167,6 +3173,10 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/rimraf@4.0.5': + resolution: {integrity: sha512-DTCZoIQotB2SUJnYgrEx43cQIUYOlNZz0AZPbKU4PSLYTUdML5Gox0++z4F9kQocxStrCmRNhi4x5x/UlwtKUA==} + deprecated: This is a stub types definition. rimraf provides its own type definitions, so you do not need this installed. + '@types/semver@7.5.8': resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} @@ -5370,6 +5380,11 @@ packages: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true + glob@11.0.0: + resolution: {integrity: sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==} + engines: {node: 20 || >=22} + hasBin: true + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -5985,6 +6000,10 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.0.2: + resolution: {integrity: sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==} + engines: {node: 20 || >=22} + jake@10.9.2: resolution: {integrity: sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==} engines: {node: '>=10'} @@ -6450,6 +6469,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.0.2: + resolution: {integrity: sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -6733,6 +6756,10 @@ packages: minimalistic-crypto-utils@1.0.1: resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} + minimatch@10.0.1: + resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} + engines: {node: 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -7209,6 +7236,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.0: + resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} + engines: {node: 20 || >=22} + path-to-regexp@0.1.12: resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} @@ -7836,6 +7867,11 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rimraf@6.0.1: + resolution: {integrity: sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==} + engines: {node: 20 || >=22} + hasBin: true + roarr@2.15.4: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} @@ -12711,6 +12747,10 @@ snapshots: dependencies: '@types/node': 20.17.10 + '@types/rimraf@4.0.5': + dependencies: + rimraf: 6.0.1 + '@types/semver@7.5.8': {} '@types/send@0.17.4': @@ -15950,6 +15990,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@11.0.0: + dependencies: + foreground-child: 3.3.0 + jackspeak: 4.0.2 + minimatch: 10.0.1 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.0 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -16634,6 +16683,10 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jackspeak@4.0.2: + dependencies: + '@isaacs/cliui': 8.0.2 + jake@10.9.2: dependencies: async: 3.2.6 @@ -17353,6 +17406,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.0.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -17873,6 +17928,10 @@ snapshots: minimalistic-crypto-utils@1.0.1: {} + minimatch@10.0.1: + dependencies: + brace-expansion: 2.0.1 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 @@ -18369,6 +18428,11 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-scurry@2.0.0: + dependencies: + lru-cache: 11.0.2 + minipass: 7.1.2 + path-to-regexp@0.1.12: {} path-type@4.0.0: {} @@ -19094,6 +19158,11 @@ snapshots: dependencies: glob: 7.2.3 + rimraf@6.0.1: + dependencies: + glob: 11.0.0 + package-json-from-dist: 1.0.1 + roarr@2.15.4: dependencies: boolean: 3.2.0 diff --git a/src/backend/__tests__/utils.test.ts b/src/backend/__tests__/utils.test.ts index c86c2cdc6e..d5ccc886e1 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 { existsSync } from 'fs' +import { rimraf } from 'rimraf' +import os from 'os' +import * as fs from 'fs' jest.mock('electron') jest.mock('../logger/logger') @@ -306,4 +312,83 @@ 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 () => { + jest.useFakeTimers({ advanceTimers: true }) + await mkdir(sourceDir, { recursive: true }) + await mkdir(destDir, { recursive: true }) + }) + + afterEach(async () => { + jest.clearAllTimers() // Clear pending timers + jest.useRealTimers() // Restore real timers + 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) + }) + }) + + const destFile = join(destDir, 'test.txt') + + // Start the copy operation but don't await it yet + const copyPromise = copyRecursiveAsync(testFile, destFile) + + // Advance timers to trigger timeout + jest.advanceTimersByTime(COPY_TIMEOUT_MS + 100) + + // Now check if it throws + await expect(copyPromise).rejects.toThrow('Timeout') + + // Restore original implementation + mockCopyFile.mockRestore() + }) + }) }) diff --git a/src/backend/ipcHandlers/mods.ts b/src/backend/ipcHandlers/mods.ts index 12d5f6bb39..c7ff7bfbd4 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 6b317d6620..068b787564 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/utils.ts b/src/backend/utils.ts index 23836ff63e..db653c1ac2 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) @@ -1334,9 +1334,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( @@ -1347,6 +1354,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}` + ) + }) + ]) } }