From 403695dcef910231f80f8160134385b4ed17c262 Mon Sep 17 00:00:00 2001 From: wbottle Date: Thu, 8 Oct 2026 18:42:42 +0400 Subject: [PATCH] fix(tests): make the suite portable across hosts --- electron/main/services/bottleLaunch.test.ts | 52 ++++++++------ electron/main/services/launchOptions.test.ts | 49 ++++++++------ .../main/services/modMerger.safety.test.ts | 4 +- .../main/services/performanceConfig.test.ts | 2 +- .../main/services/steamRoots.driveMap.test.ts | 67 +++++++++++++++++++ electron/main/services/steamRoots.test.ts | 39 ++++------- electron/main/services/steamRoots.ts | 4 +- electron/main/services/system.test.ts | 8 +-- 8 files changed, 147 insertions(+), 78 deletions(-) create mode 100644 electron/main/services/steamRoots.driveMap.test.ts diff --git a/electron/main/services/bottleLaunch.test.ts b/electron/main/services/bottleLaunch.test.ts index 11346be1..07b1dadb 100644 --- a/electron/main/services/bottleLaunch.test.ts +++ b/electron/main/services/bottleLaunch.test.ts @@ -1,13 +1,22 @@ -import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; -import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, chmodSync } from 'fs'; +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; +import type { SpawnOptions } from 'child_process'; import { join } from 'path'; import { tmpdir } from 'os'; import { launchAppInBottle, findBottleRunner, BottleRunnerMissingError } from './bottleLaunch'; import type { SteamBottle } from './steamRoots'; -// Rather than mock child_process, point GRIMOIRE_WINE at a real script that -// records its argv. That exercises the actual spawn call, so a broken argument -// order or a missing separator shows up here instead of at runtime. +// CrossOver's runner cannot run on Windows. Execute its fixture through Node +// while preserving the real detached spawn, arguments and stdio behavior. +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: (command: string, args: string[], options: SpawnOptions) => + actual.spawn(process.execPath, [command, ...args], options), + }; +}); + let root: string; let fakeWine: string; let argvLog: string; @@ -16,9 +25,11 @@ let bottle: SteamBottle; beforeAll(() => { root = mkdtempSync(join(tmpdir(), 'grimoire-launch-')); argvLog = join(root, 'argv.txt'); - fakeWine = join(root, 'fake-wine'); - writeFileSync(fakeWine, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argvLog}"\n`); - chmodSync(fakeWine, 0o755); + fakeWine = join(root, 'fake-wine.cjs'); + writeFileSync(fakeWine, [ + "const { writeFileSync } = require('node:fs');", + `writeFileSync(${JSON.stringify(argvLog)}, process.argv.slice(2).join('\\n'));`, + ].join('\n')); const steamRoot = join(root, 'Bottles', 'Deadlock', 'drive_c', 'Program Files (x86)', 'Steam'); mkdirSync(steamRoot, { recursive: true }); @@ -31,24 +42,21 @@ afterAll(() => { }); afterEach(() => { - delete process.env.GRIMOIRE_WINE; + vi.unstubAllEnvs(); }); /** Wait for the detached child to write its log, since spawn is async. */ async function readArgv(): Promise { - for (let attempt = 0; attempt < 50; attempt++) { - if (existsSync(argvLog)) { - const lines = readFileSync(argvLog, 'utf-8').trim().split('\n'); - if (lines.length > 1) return lines; - } - await new Promise((r) => setTimeout(r, 20)); - } - throw new Error('fake wine never ran'); + return vi.waitFor(() => { + const lines = readFileSync(argvLog, 'utf-8').trim().split('\n'); + expect(lines.length).toBeGreaterThan(1); + return lines; + }, { timeout: 5000 }); } describe('launchAppInBottle', () => { it('invokes the runner with the bottle, a separator, and the app id', async () => { - process.env.GRIMOIRE_WINE = fakeWine; + vi.stubEnv('GRIMOIRE_WINE', fakeWine); launchAppInBottle(bottle, 1422450); expect(await readArgv()).toEqual([ @@ -62,12 +70,12 @@ describe('launchAppInBottle', () => { }); it('fails loudly when no Wine runner is available', () => { - process.env.GRIMOIRE_WINE = join(root, 'nope'); + vi.stubEnv('GRIMOIRE_WINE', join(root, 'nope')); expect(() => launchAppInBottle(bottle, 1422450)).toThrow(BottleRunnerMissingError); }); it('fails when the bottle has no steam.exe', () => { - process.env.GRIMOIRE_WINE = fakeWine; + vi.stubEnv('GRIMOIRE_WINE', fakeWine); const empty: SteamBottle = { ...bottle, steamRoot: join(root, 'empty') }; expect(() => launchAppInBottle(empty, 1422450)).toThrow(/No steam\.exe/); }); @@ -75,12 +83,12 @@ describe('launchAppInBottle', () => { describe('findBottleRunner', () => { it('honours the GRIMOIRE_WINE override', () => { - process.env.GRIMOIRE_WINE = fakeWine; + vi.stubEnv('GRIMOIRE_WINE', fakeWine); expect(findBottleRunner()).toBe(fakeWine); }); it('reports nothing rather than a bad path when the override does not exist', () => { - process.env.GRIMOIRE_WINE = join(root, 'nope'); + vi.stubEnv('GRIMOIRE_WINE', join(root, 'nope')); expect(findBottleRunner()).toBeNull(); }); }); diff --git a/electron/main/services/launchOptions.test.ts b/electron/main/services/launchOptions.test.ts index 7689a95b..909b78ca 100644 --- a/electron/main/services/launchOptions.test.ts +++ b/electron/main/services/launchOptions.test.ts @@ -1,16 +1,27 @@ -import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; -import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, chmodSync } from 'fs'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs'; +import type { SpawnOptions } from 'child_process'; import { join } from 'path'; import { tmpdir } from 'os'; import { isSteamRunning } from './launchOptions'; -// Rather than mock child_process, put a fake `pgrep` at the front of PATH that -// logs its argv and reports a match only for the pattern named in GRIMOIRE_TEST_MATCH. -// That exercises the real spawn, so a wrong flag or a dropped second probe -// shows up here instead of at runtime. +const fixture = vi.hoisted(() => ({ command: '' })); + +// pgrep cannot run on Windows. Replace only its executable with a Node fixture, +// keeping the real child process, arguments, options and exit-code handling. +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: (command: string, args: string[], options: SpawnOptions) => { + if (command !== 'pgrep') throw new Error(`Unexpected command: ${command}`); + return actual.spawn(process.execPath, [fixture.command, ...args], options); + }, + }; +}); + let root: string; let argvLog: string; -let realPath: string | undefined; let realPlatform: PropertyDescriptor | undefined; function setPlatform(value: NodeJS.Platform) { @@ -27,28 +38,22 @@ beforeAll(() => { root = mkdtempSync(join(tmpdir(), 'grimoire-launchopts-')); argvLog = join(root, 'pgrep-argv.txt'); - const fakePgrep = join(root, 'pgrep'); + fixture.command = join(root, 'pgrep.cjs'); writeFileSync( - fakePgrep, + fixture.command, [ - '#!/bin/sh', - `printf '%s\\n' "$*" >> "${argvLog}"`, - 'case "$*" in', - ' *"$GRIMOIRE_TEST_MATCH"*) exit 0 ;;', - 'esac', - 'exit 1', + "const { appendFileSync } = require('node:fs');", + "const args = process.argv.slice(2).join(' ');", + "appendFileSync(process.env.GRIMOIRE_TEST_LOG, args + '\\n');", + 'process.exit(args.includes(process.env.GRIMOIRE_TEST_MATCH) ? 0 : 1);', ].join('\n') + '\n', ); - chmodSync(fakePgrep, 0o755); - - realPath = process.env.PATH; - process.env.PATH = `${root}:${realPath ?? ''}`; + vi.stubEnv('GRIMOIRE_TEST_LOG', argvLog); realPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); }); afterAll(() => { - process.env.PATH = realPath; - delete process.env.GRIMOIRE_TEST_MATCH; + vi.unstubAllEnvs(); if (realPlatform) Object.defineProperty(process, 'platform', realPlatform); rmSync(root, { recursive: true, force: true }); }); @@ -56,7 +61,7 @@ afterAll(() => { beforeEach(() => { rmSync(argvLog, { force: true }); // A pattern no invocation can contain, so "nothing is running" is the default. - process.env.GRIMOIRE_TEST_MATCH = '__no_match__'; + vi.stubEnv('GRIMOIRE_TEST_MATCH', '__no_match__'); }); describe('isSteamRunning on darwin', () => { diff --git a/electron/main/services/modMerger.safety.test.ts b/electron/main/services/modMerger.safety.test.ts index 5890bd63..69708165 100644 --- a/electron/main/services/modMerger.safety.test.ts +++ b/electron/main/services/modMerger.safety.test.ts @@ -4,6 +4,7 @@ * claim a live slot, and a merge of trusted sources must not ask again. */ import { EventEmitter } from 'events'; +import { basename, dirname, join } from 'path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const fsMocks = vi.hoisted(() => ({ @@ -179,7 +180,8 @@ describe('refused merge outputs', () => { await expect(extractMergeSource('/game', target.id, a.fileName)).rejects.toThrow('MOD_SAFETY_TRUST_REQUIRED'); const buildPath = String(safetyMocks.assertVpkSafety.mock.calls[0][0]); - expect(buildPath).toMatch(/^\/game\/addons\/\.merge-rebuild-.*\.tmp$/); + expect(dirname(buildPath)).toBe(join('/game', 'addons')); + expect(basename(buildPath)).toMatch(/^\.merge-rebuild-.*\.tmp$/); expect(fsMocks.unlink).toHaveBeenCalledWith(buildPath); expect(fsMocks.rename).not.toHaveBeenCalled(); expect(metadataMocks.setModMetadata).not.toHaveBeenCalled(); diff --git a/electron/main/services/performanceConfig.test.ts b/electron/main/services/performanceConfig.test.ts index e1ca3090..a3e51725 100644 --- a/electron/main/services/performanceConfig.test.ts +++ b/electron/main/services/performanceConfig.test.ts @@ -23,7 +23,7 @@ import { import { CLASSIFICATION, getPreset } from './performanceConfigData'; import { pathExcluded } from './performancePresetGen'; -const STOCK = readFileSync(join(__dirname, '__fixtures__/stock-gameinfo.gi'), 'utf-8'); +const STOCK = readFileSync(join(__dirname, '__fixtures__/stock-gameinfo.gi'), 'utf-8').replace(/\r\n/g, '\n'); const STOCK_CRLF = STOCK.split('\n').join('\r\n'); const PRESETS = listPerformancePresets(); diff --git a/electron/main/services/steamRoots.driveMap.test.ts b/electron/main/services/steamRoots.driveMap.test.ts new file mode 100644 index 00000000..aa7a9aa9 --- /dev/null +++ b/electron/main/services/steamRoots.driveMap.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { basename, join, resolve } from 'path'; +import { tmpdir } from 'os'; +import { readBottleDriveMap } from './steamRoots'; + +const fsMocks = vi.hoisted(() => ({ + readdirSync: vi.fn<() => string[]>(), + readlinkSync: vi.fn<(path: string) => string>(), + existsSync: vi.fn<(path: string) => boolean>(), +})); + +// Wine's c: filenames cannot be created on Windows. Mock those filesystem +// entries only; bottle discovery is tested against real folders in steamRoots.test. +vi.mock('fs', async (importOriginal) => ({ + ...await importOriginal(), + ...fsMocks, +})); + +const bottle = join(tmpdir(), 'grimoire-drive-map', 'Deadlock'); +const hostLibrary = join(tmpdir(), 'grimoire-drive-map', 'HostGames'); + +beforeEach(() => { + fsMocks.readdirSync.mockReturnValue(['c:', 'D:', 'z:', 'c::', 'com1']); + fsMocks.existsSync.mockReturnValue(false); + fsMocks.readlinkSync.mockImplementation((path) => { + switch (basename(path)) { + case 'c:': return '../drive_c'; + case 'D:': return hostLibrary; + case 'z:': return resolve(tmpdir(), '..'); + default: throw new Error(`Unexpected drive link: ${path}`); + } + }); + vi.clearAllMocks(); +}); + +describe('readBottleDriveMap', () => { + it('resolves relative drive links against dosdevices', () => { + expect(readBottleDriveMap(bottle)['c:']).toBe(join(bottle, 'drive_c')); + expect(fsMocks.readlinkSync).toHaveBeenCalledWith(join(bottle, 'dosdevices', 'c:')); + }); + + it('keeps absolute targets, normalizes drive letters and ignores port/device links', () => { + expect(readBottleDriveMap(bottle)).toEqual({ + 'c:': join(bottle, 'drive_c'), 'd:': hostLibrary, 'z:': resolve(tmpdir(), '..'), + }); + expect(fsMocks.readlinkSync).toHaveBeenCalledTimes(3); + }); + + it('returns an empty map when dosdevices is missing', () => { + fsMocks.readdirSync.mockImplementationOnce(() => { throw new Error('Missing directory'); }); + expect(readBottleDriveMap(bottle)).toEqual({}); + expect(fsMocks.readlinkSync).not.toHaveBeenCalled(); + }); + + it('uses an existing directory when a drive entry is not a symlink', () => { + fsMocks.readdirSync.mockReturnValue(['c:']); + fsMocks.readlinkSync.mockImplementation(() => { throw new Error('Not a symlink'); }); + fsMocks.existsSync.mockReturnValue(true); + expect(readBottleDriveMap(bottle)).toEqual({ 'c:': join(bottle, 'dosdevices', 'c:') }); + }); + + it('omits a drive entry whose link and directory cannot be read', () => { + fsMocks.readdirSync.mockReturnValue(['c:']); + fsMocks.readlinkSync.mockImplementation(() => { throw new Error('Unreadable link'); }); + expect(readBottleDriveMap(bottle)).toEqual({}); + }); +}); diff --git a/electron/main/services/steamRoots.test.ts b/electron/main/services/steamRoots.test.ts index 6789e6d0..4fc46115 100644 --- a/electron/main/services/steamRoots.test.ts +++ b/electron/main/services/steamRoots.test.ts @@ -1,11 +1,10 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync, rmSync } from 'fs'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { findSteamBottles, findBottleForPath, - readBottleDriveMap, resolveBottleWindowsPath, } from './steamRoots'; @@ -31,14 +30,6 @@ beforeAll(() => { mkdirSync(driveC, { recursive: true }); if (steamSubPath) mkdirSync(join(driveC, steamSubPath), { recursive: true }); - const dosdevices = join(bottle, 'dosdevices'); - mkdirSync(dosdevices, { recursive: true }); - // Wine writes c: as a RELATIVE link, which is the case that breaks a - // naive readlink consumer. - symlinkSync('../drive_c', join(dosdevices, 'c:')); - symlinkSync('/', join(dosdevices, 'z:')); - // A non-drive link that must be ignored. - symlinkSync('/dev/null', join(dosdevices, 'com1')); return bottle; }; @@ -48,8 +39,6 @@ beforeAll(() => { makeBottle('Legacy', join('Program Files', 'Steam')); writeFileSync(join(bottlesDir, 'notabottle'), 'stray file'); - // A second Steam library on its own drive letter, living outside drive_c. - symlinkSync(hostLibrary, join(bottlesDir, 'Deadlock', 'dosdevices', 'd:')); }); afterAll(() => { @@ -74,21 +63,11 @@ describe('findSteamBottles', () => { }); }); -describe('readBottleDriveMap', () => { - it('resolves relative drive links against the dosdevices dir', () => { - const map = readBottleDriveMap(join(bottlesDir, 'Deadlock')); - expect(map['c:']).toBe(join(bottlesDir, 'Deadlock', 'drive_c')); - }); - - it('keeps absolute drive links as-is and ignores non-drive links', () => { - const map = readBottleDriveMap(join(bottlesDir, 'Deadlock')); - expect(map['z:']).toBe('/'); - expect(map).not.toHaveProperty('com1'); - }); -}); - describe('resolveBottleWindowsPath', () => { - const map = () => readBottleDriveMap(join(bottlesDir, 'Deadlock')); + const map = () => ({ + 'c:': join(bottlesDir, 'Deadlock', 'drive_c'), + 'd:': hostLibrary, + }); it('maps a drive_c Windows path to its host path', () => { expect(resolveBottleWindowsPath('C:\\Program Files (x86)\\Steam', map())).toBe( @@ -124,6 +103,10 @@ describe('resolveBottleWindowsPath', () => { }); describe('findBottleForPath', () => { + it('matches the bottle root itself', () => { + expect(findBottleForPath(join(bottlesDir, 'Deadlock'), bottlesDir)?.name).toBe('Deadlock'); + }); + it('finds the bottle containing a deep game path', () => { const gamePath = join( bottlesDir, @@ -146,4 +129,8 @@ describe('findBottleForPath', () => { it('returns null for a path outside any bottle', () => { expect(findBottleForPath(hostLibrary, bottlesDir)).toBeNull(); }); + + it('does not match an unrelated folder with a bottle-name prefix', () => { + expect(findBottleForPath(join(bottlesDir, 'DeadlockBackup', 'drive_c'), bottlesDir)).toBeNull(); + }); }); diff --git a/electron/main/services/steamRoots.ts b/electron/main/services/steamRoots.ts index 37da5b1e..60b2bb77 100644 --- a/electron/main/services/steamRoots.ts +++ b/electron/main/services/steamRoots.ts @@ -13,7 +13,7 @@ // and correct the day Valve ships a macOS build). import { existsSync, readdirSync, readlinkSync, statSync } from 'fs'; -import { join, resolve, isAbsolute } from 'path'; +import { join, resolve, isAbsolute, sep } from 'path'; import { homedir } from 'os'; import { execFileSync } from 'child_process'; @@ -144,7 +144,7 @@ export function findBottleForPath( const normalized = resolve(hostPath); for (const bottle of findSteamBottles(bottlesDir)) { const root = resolve(bottle.bottlePath); - if (normalized === root || normalized.startsWith(root + '/')) return bottle; + if (normalized === root || normalized.startsWith(root + sep)) return bottle; } return null; } diff --git a/electron/main/services/system.test.ts b/electron/main/services/system.test.ts index 23ff44fc..acaaed8b 100644 --- a/electron/main/services/system.test.ts +++ b/electron/main/services/system.test.ts @@ -8,7 +8,7 @@ import { findSearchPathsBlock, hasActivePath } from './gameinfoSearchPaths'; vi.mock('./replayFolder', () => ({ ensureReplayFolderLink: vi.fn() })); const roots: string[] = []; -const stock = readFileSync(new URL('./__fixtures__/stock-gameinfo.gi', import.meta.url), 'utf8'); +const stock = readFileSync(new URL('./__fixtures__/stock-gameinfo.gi', import.meta.url), 'utf8').replace(/\r\n/g, '\n'); const legacyBody = ` Game citadel/grimoire Game citadel/addons @@ -194,8 +194,8 @@ describe('gameinfo search-path repair', () => { expect(hasActivePath(body, 'citadel/addons')).toBe(true); }); - it('keeps the block stable across repeated repairs', () => { - const game = install(stock.replace('Game_Language', 'Game_UILanguage')); + it.each(['\n', '\r\n'])('keeps the block stable across repeated repairs (%j)', (eol) => { + const game = install(stock.replace('Game_Language', 'Game_UILanguage').replaceAll('\n', eol)); fixGameinfo(game.root); const first = game.read(); mkdirSync(join(game.citadel, 'addons1')); @@ -206,7 +206,7 @@ describe('gameinfo search-path repair', () => { rmSync(join(game.citadel, 'deadworks_addons'), { recursive: true }); writeFileSync(game.path, game.read().replace('Game_UILanguage', 'Game_Language')); fixGameinfo(game.root); - expect(game.read()).toBe(first.replace('citadel/addons\n', 'citadel/addons\n\t\t\tGame_UILanguage\t\tcitadel_*LANGUAGE*\n') + expect(game.read()).toBe(first.replace(`citadel/addons${eol}`, `citadel/addons${eol}\t\t\tGame_UILanguage\t\tcitadel_*LANGUAGE*${eol}`) .replace('Game_UILanguage "', 'Game_Language "')); });