Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 30 additions & 22 deletions electron/main/services/bottleLaunch.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('child_process')>();
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;
Expand All @@ -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 });
Expand All @@ -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<string[]> {
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([
Expand All @@ -62,25 +70,25 @@ 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/);
});
});

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();
});
});
49 changes: 27 additions & 22 deletions electron/main/services/launchOptions.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('child_process')>();
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) {
Expand All @@ -27,36 +38,30 @@ 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 });
});

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', () => {
Expand Down
4 changes: 3 additions & 1 deletion electron/main/services/modMerger.safety.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion electron/main/services/performanceConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
67 changes: 67 additions & 0 deletions electron/main/services/steamRoots.driveMap.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('fs')>(),
...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({});
});
});
39 changes: 13 additions & 26 deletions electron/main/services/steamRoots.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;
};

Expand All @@ -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(() => {
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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();
});
});
4 changes: 2 additions & 2 deletions electron/main/services/steamRoots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading