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
2 changes: 1 addition & 1 deletion vortex-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"watch": "webpack --config webpack.config.cjs --mode development --watch",
"lint": "eslint src test",
"test": "vitest run src",
"test:integration": "vitest run test"
"test:integration": "vitest run test --no-file-parallelism"
},
"overrides": {
"react": "16.14.0",
Expand Down
15 changes: 13 additions & 2 deletions vortex-extension/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,15 @@ import main from './index';
import { WITCHER3_GAME_ID } from './gating';

/** A minimal stand-in for IExtensionContext - just enough surface for index.ts's own
* logic (context.once, context.api.getState/events.on/onAsync), matching gating.test.ts's
* own fakeApi philosophy: a simplified fake, not a replica of Vortex's real context shape.
* logic (context.once, context.registerDashlet, context.api.getState/events.on/onAsync),
* matching gating.test.ts's own fakeApi philosophy: a simplified fake, not a replica of
* Vortex's real context shape.
*
* `registerDashlet` needs a real (no-op) implementation, not just a type - added
* alongside mergeHistoryDashlet.ts's registerMergeHistoryDashlet, which main() now calls
* synchronously (see index.ts's own comment on why that call sits outside context.once)
* - without this, every test below would throw "context.registerDashlet is not a
* function" the moment main(context) runs.
*
* `profiles` backs `selectors.profileById` (via the shared `vortexApiStub.ts`) -
* `checkForConflictsAfterDeploy` (index.ts) resolves `did-deploy`'s own `profileId`
Expand All @@ -57,6 +64,10 @@ function fakeContext(initialActiveGameId: string | undefined, profiles: Record<s
once: (callback: () => void) => {
onceCallback = callback;
},
registerDashlet: (..._args: unknown[]) => {
// Intentionally a no-op in tests - mergeHistoryDashlet.test.ts covers
// registerMergeHistoryDashlet's own argument-shape/gating behavior directly.
},
api: {
getState: () => state,
events: {
Expand Down
25 changes: 19 additions & 6 deletions vortex-extension/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { log, selectors, types } from 'vortex-api';
import { isWsmToolAcquired, scanWsmConflicts } from './conflictScan';
import { isModOrDependencyInstallActive, notifyConflictsIfChanged } from './conflictNotifications';
import { isWitcher3Active, WITCHER3_GAME_ID } from './gating';
import { registerMergeHistoryDashlet } from './mergeHistoryDashlet';
import { ensureWsmToolRegistered } from './toolAcquisition';

/**
Expand Down Expand Up @@ -38,12 +39,18 @@ import { ensureWsmToolRegistered } from './toolAcquisition';
* onDidDeploy(context.api))`) both register it that way - see this unit's PR
* description for the exact citations.
*
* Later units (the merge panel, dashlets) each add their own `context.register*` calls
* inside the `context.once(...)` callback below, gated on `isWitcher3Active` (imported
* from `./gating`) - preferably via each registration API's own `condition` callback,
* so a live game-mode switch is honored without requiring a Vortex restart, the same
* way `tryRegisterWsmTool` below re-checks it on every `'gamemode-activated'` event
* rather than only once.
* This unit (the merge-history dashlet) adds the third real registration:
* `registerMergeHistoryDashlet` (`./mergeHistoryDashlet`), called directly in `main`,
* NOT deferred into the `context.once(...)` callback below - `IExtensionContext.once`'s
* own doc comment (`@nexusmods/vortex-api`'s `lib/api.d.ts`) says registration calls are
* expected to have already happened by the time `once` fires, matching every
* `registerDashlet`/`registerAction` call site in Vortex's own built-in extensions
* (none of them defer through `once`). Each registration instead gates on
* `isWitcher3Active` (imported from `./gating`) via its own `condition`/`isVisible`
* callback, so a live game-mode switch is honored without requiring a Vortex restart -
* the declarative counterpart to how `tryRegisterWsmTool` below re-checks the same
* condition imperatively on every `'gamemode-activated'` event. Later units (the resolve
* action, status tile) follow this same directly-in-`main` pattern.
*
* This extension must never call `context.registerGame('witcher3', ...)` - Vortex's own
* built-in `game-witcher3` extension already owns that registration; this extension is a
Expand Down Expand Up @@ -151,6 +158,12 @@ function main(context: types.IExtensionContext): boolean {
}
}

// Registered synchronously here, not inside context.once below - see
// mergeHistoryDashlet.ts's own registerMergeHistoryDashlet doc comment for why a
// register call specifically must not be deferred into once, unlike
// tryRegisterWsmTool's legitimate use of once just above.
registerMergeHistoryDashlet(context);

context.once(() => {
tryRegisterWsmTool();
context.api.events.on('gamemode-activated', tryRegisterWsmTool);
Expand Down
179 changes: 179 additions & 0 deletions vortex-extension/src/mergeHistoryDashlet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { types } from 'vortex-api';
import { WITCHER3_GAME_ID } from './gating';
import { fetchMergeHistory, registerMergeHistoryDashlet, resolveWsmExePath } from './mergeHistoryDashlet';
import { WsmMcpClient } from './mcpClient';
import { WSM_HEADLESS_EXE_NAME } from './toolAcquisition';

// vitest never mounts/renders MergeHistoryDashlet itself here - vitest.config.ts runs in
// vitest's default 'node' environment (no jsdom), so there's no DOM to mount a React
// component into. What's tested instead: fetchMergeHistory's own data-fetch/lifecycle
// contract (the part with real logic - deciding what to fetch, always closing the
// client) and registerMergeHistoryDashlet's own registration-call shape/gating, mirroring
// how index.test.ts exercises index.ts's wiring without ever rendering anything either.

function fakeApi(userDataDir: string) {
return {
getPath: (name: string) => (name === 'userData' ? userDataDir : `/unexpected/${name}`),
getState: () => ({ activeGameId: WITCHER3_GAME_ID }),
} as unknown as types.IExtensionApi;
}

describe('resolveWsmExePath', () => {
let userDataDir: string;

beforeEach(() => {
userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wsm-vortex-history-test-'));
});

afterEach(() => {
fs.rmSync(userDataDir, { recursive: true, force: true });
});

it('returns null when no WSM build has been acquired yet', () => {
expect(resolveWsmExePath(fakeApi(userDataDir))).toBeNull();
});

it('returns the exe path when a WSM build has been acquired', () => {
const toolDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'tool');
fs.mkdirSync(toolDir, { recursive: true });
const exePath = path.join(toolDir, WSM_HEADLESS_EXE_NAME);
fs.writeFileSync(exePath, 'fake exe bytes', 'utf8');

expect(resolveWsmExePath(fakeApi(userDataDir))).toBe(exePath);
});
});

describe('fetchMergeHistory', () => {
let userDataDir: string;

beforeEach(() => {
userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wsm-vortex-history-fetch-test-'));
});

afterEach(() => {
fs.rmSync(userDataDir, { recursive: true, force: true });
});

it('returns not-installed without attempting to connect when no WSM build is acquired', async () => {
const connect = vi.fn();

const result = await fetchMergeHistory(fakeApi(userDataDir), { connect });

expect(result).toEqual({ status: 'not-installed' });
expect(connect).not.toHaveBeenCalled();
});

function acquireFakeExe(): string {
const toolDir = path.join(userDataDir, 'witcherscriptmerger-vortex', 'tool');
fs.mkdirSync(toolDir, { recursive: true });
const exePath = path.join(toolDir, WSM_HEADLESS_EXE_NAME);
fs.writeFileSync(exePath, 'fake exe bytes', 'utf8');
return exePath;
}

it('returns loaded merges and closes the client on success', async () => {
const exePath = acquireFakeExe();
const merges = [
{
relativePath: 'content\\scripts\\game\\r4Game.ws',
mergedModName: 'mod0000_MergedFiles',
mods: [{ name: 'modAlpha', hash: '1a2b3c4d' }],
},
];
const close = vi.fn().mockResolvedValue(undefined);
const listMerges = vi.fn().mockResolvedValue(merges);
const fakeClient = { listMerges, close } as unknown as WsmMcpClient;
const connect = vi.fn().mockResolvedValue(fakeClient);

const result = await fetchMergeHistory(fakeApi(userDataDir), { connect });

expect(connect).toHaveBeenCalledWith({ exePath });
expect(result).toEqual({ status: 'loaded', merges });
expect(close).toHaveBeenCalledTimes(1);
});

it('returns an error result but still closes the client when listMerges rejects', async () => {
acquireFakeExe();
const close = vi.fn().mockResolvedValue(undefined);
const listMerges = vi.fn().mockRejectedValue(new Error('tool call failed'));
const fakeClient = { listMerges, close } as unknown as WsmMcpClient;
const connect = vi.fn().mockResolvedValue(fakeClient);

const result = await fetchMergeHistory(fakeApi(userDataDir), { connect });

expect(result).toEqual({ status: 'error', message: 'tool call failed' });
// The documented "close in a finally" policy (mcpClient.ts) - a failed tool call
// must never leak the spawned WSM process.
expect(close).toHaveBeenCalledTimes(1);
});

it('returns an error result without attempting to close anything when connect itself rejects', async () => {
acquireFakeExe();
const connect = vi.fn().mockRejectedValue(new Error('spawn failed'));

const result = await fetchMergeHistory(fakeApi(userDataDir), { connect });

expect(result).toEqual({ status: 'error', message: 'spawn failed' });
});

it('wraps a non-Error rejection into a string message rather than throwing', async () => {
acquireFakeExe();
const connect = vi.fn().mockRejectedValue('a plain string failure');

const result = await fetchMergeHistory(fakeApi(userDataDir), { connect });

expect(result).toEqual({ status: 'error', message: 'a plain string failure' });
});
});

describe('registerMergeHistoryDashlet', () => {
function fakeContext(activeGameId: string | undefined) {
const state = { activeGameId };
const registerDashlet = vi.fn();
const context = {
api: { getState: () => state },
registerDashlet,
} as unknown as types.IExtensionContext;
return { context, registerDashlet, setActiveGame: (id: string | undefined) => (state.activeGameId = id) };
}

it('registers a dashlet with the expected title/size/position/options shape', () => {
const { context, registerDashlet } = fakeContext(WITCHER3_GAME_ID);

registerMergeHistoryDashlet(context);

expect(registerDashlet).toHaveBeenCalledTimes(1);
const [title, width, height, position, component, , , options] = registerDashlet.mock.calls[0];
expect(title).toBe('WitcherScriptMerger History');
expect(width).toBeGreaterThanOrEqual(1);
expect(height).toBeGreaterThanOrEqual(1);
expect(typeof position).toBe('number');
expect(component).toBeTruthy();
expect(options).toEqual({ closable: true });
});

it('passes context.api through the props callback', () => {
const { context, registerDashlet } = fakeContext(WITCHER3_GAME_ID);

registerMergeHistoryDashlet(context);

const propsCallback = registerDashlet.mock.calls[0][6] as () => { api: unknown };
expect(propsCallback().api).toBe(context.api);
});

it('gates isVisible on isWitcher3Active, re-evaluated live rather than cached at registration time', () => {
const { context, registerDashlet, setActiveGame } = fakeContext('skyrimse');

registerMergeHistoryDashlet(context);

const isVisible = registerDashlet.mock.calls[0][5] as (state: unknown) => boolean;
expect(isVisible(undefined)).toBe(false);

setActiveGame(WITCHER3_GAME_ID);
expect(isVisible(undefined)).toBe(true);
});
});
Loading
Loading