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
44 changes: 31 additions & 13 deletions vortex-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,37 @@ npm test # fast, Node-only unit tests
```

`npm test` only runs the fast, Node-only unit tests (`src/**/*.test.ts`) - no .NET SDK
needed. `src/mcpClient.ts`'s real, spawned-process integration test
(`test/mcpClient.integration.test.ts`) is a separate script, `npm run test:integration`,
since it needs a local .NET SDK and a buildable `WitcherScriptMerger.Headless` (it will
run `dotnet build` itself if the exe isn't already present) - kept out of the default
`npm test` so a Node-only environment (e.g. a contributor machine or CI runner without
the .NET SDK on `PATH`) isn't forced through a multi-minute .NET build just to iterate on
this extension's own TypeScript.
needed. The real, spawned-process integration tests are a separate script, `npm run
test:integration`, since they need a local .NET SDK and a built/published
`WitcherScriptMerger.Headless` - kept out of the default `npm test` so a Node-only
environment (e.g. a contributor machine or CI runner without the .NET SDK on `PATH`)
isn't forced through a multi-minute .NET build just to iterate on this extension's own
TypeScript. Two different `WitcherScriptMerger.Headless` invocations are involved:
`test/mcpClient.integration.test.ts` runs a plain `dotnet build` itself if the exe isn't
already present (framework-dependent, fast); `test/toolAcquisition.integration.test.ts`
instead runs `dotnet publish -c Release -p:PublishProfile=win-x64` (self-contained,
single-file, matching `.github/workflows/release.yml`'s own publish step exactly) if
that specific publish output isn't already present - slower on a cold run (produces a
~78 MB standalone exe) since it stands in for a downloaded-and-extracted release asset,
which the plain `dotnet build` output doesn't represent.

## Status

This is the foundation scaffold (info.json manifest, build tooling, the `init(context)`
entry point with only game-activity gating wired up, and the shared MCP stdio client in
`src/mcpClient.ts`). No actual features - tool acquisition, conflict scanning, the merge
panel, dashlets - are implemented here; those are separate, later units built on top of
this scaffold. See `docs/vortex-extension-design.md` (once merged) for the fuller design
context this scaffold follows.
The foundation scaffold (info.json manifest, build tooling, the `init(context)` entry
point, and the shared MCP stdio client in `src/mcpClient.ts`) is in place, plus one real
feature: **tool acquisition**. `src/toolAcquisition.ts` downloads a WSM release build
from GitHub Releases, verifies/extracts it, and registers it as a discovered Vortex tool
(`src/discoveredTool.ts`, tool ID `WitcherScriptMergerEnhanced` - distinct from Vortex's
own built-in `game-witcher3` extension's `W3ScriptMerger`). `src/wsmEnv.ts` builds the
`WSM_<KeyName>` environment-variable overrides (see
`WitcherScriptMerger.Core/AppSettings.cs`) used to configure a spawned WSM process -
never by editing its `.exe.config`/`.dll.config` XML. **The actual GitHub-Releases
download path is unverified against a real release** - no version tag has been pushed to
this repo yet, so no release exists; see `src/githubRelease.ts`'s own doc comment and
this feature's own PR description for exactly what was verified instead (a mocked-HTTP
unit test for the download logic, plus a full acquisition/registration/env-var-config
integration test using a locally-built binary standing in for a downloaded one).

Conflict scanning, the merge panel, and dashlets are separate, later units not yet built
on top of this scaffold. See `docs/vortex-extension-design.md` for the fuller design
context this scaffold and the tool-acquisition unit follow.
2 changes: 2 additions & 0 deletions vortex-extension/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

56 changes: 56 additions & 0 deletions vortex-extension/src/archiveExtractor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createVortexArchiveExtractor } from './archiveExtractor';

// This only tests createVortexArchiveExtractor's own glue (destDir creation, delegating
// to api.openArchive/archive.extractAll, error handling when extractAll is missing) -
// the real archive-handler behavior behind api.openArchive is Vortex's own, and isn't
// something this repo can exercise without a real Vortex host (see archiveExtractor.ts's
// own doc comment).
function fakeApi(openArchive: (archivePath: string, options?: unknown) => Promise<{ extractAll?: (dest: string) => Promise<void> }>) {
return { openArchive } as unknown as Parameters<typeof createVortexArchiveExtractor>[0];
}

describe('createVortexArchiveExtractor', () => {
let scratchDir: string;

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

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

it('creates destDir, opens the archive, and calls extractAll(destDir)', async () => {
const destDir = path.join(scratchDir, 'nested', 'destination');
const extractAllCalls: string[] = [];
let openArchiveCall: { archivePath: string; options: unknown } | undefined;

const extractor = createVortexArchiveExtractor(
fakeApi(async (archivePath, options) => {
openArchiveCall = { archivePath, options };
return {
extractAll: async (dest: string) => {
extractAllCalls.push(dest);
},
};
}),
);

await extractor.extractAll('C:\\fake\\asset.zip', destDir);

expect(fs.existsSync(destDir)).toBe(true);
expect(openArchiveCall?.archivePath).toBe('C:\\fake\\asset.zip');
expect(extractAllCalls).toEqual([destDir]);
});

it('throws a clear error when the opened archive has no extractAll', async () => {
const destDir = path.join(scratchDir, 'destination');
const extractor = createVortexArchiveExtractor(fakeApi(async () => ({})));

await expect(extractor.extractAll('C:\\fake\\asset.zip', destDir)).rejects.toThrow(/does not support/);
});
});
41 changes: 41 additions & 0 deletions vortex-extension/src/archiveExtractor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as fs from 'fs';
import { types } from 'vortex-api';

/**
* Extracts a downloaded WSM release archive. Deliberately goes through Vortex's own
* `api.openArchive`/`Archive.extractAll` (`@nexusmods/vortex-api`'s own `lib/api.d.ts`
* documents `openArchive(archivePath, options?, extension?): Promise<Archive>` with an
* `extractAll(outputPath): Promise<void>` member, backed by whatever archive-handler
* extension Vortex has registered for the file's format) rather than a hand-rolled zip
* reader or a new npm dependency - this is the idiomatic mechanism a Vortex extension
* already has for exactly this job.
*
* Behind a one-function interface (`ArchiveExtractor`) so `toolAcquisition.ts` stays
* unit-testable with extraction stubbed - this real implementation is never exercised
* by any test in this repo, since doing so would need a real Vortex host providing a
* real archive-handler extension (`api.openArchive` has no meaningful behavior outside
* one). See this unit's PR description for what was/wasn't verified.
*/
export interface ArchiveExtractor {
extractAll(archivePath: string, destDir: string): Promise<void>;
}

export function createVortexArchiveExtractor(api: types.IExtensionApi): ArchiveExtractor {
return {
async extractAll(archivePath: string, destDir: string): Promise<void> {
await fs.promises.mkdir(destDir, { recursive: true });

// verify: true requests whatever integrity check Vortex's own archive handler
// supports (a CRC pass, or possibly nothing, depending on the handler) - its exact
// behavior is unverified here, like the rest of this real implementation (see this
// function's own doc comment above).
const archive = await api.openArchive(archivePath, { verify: true });
if (!archive.extractAll) {
throw new Error(
`Vortex's archive handler for '${archivePath}' does not support extracting the whole archive (extractAll is undefined).`,
);
}
await archive.extractAll(destDir);
},
};
}
79 changes: 79 additions & 0 deletions vortex-extension/src/discoveredTool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import * as path from 'path';
import { describe, expect, it, vi } from 'vitest';
import { WITCHER3_GAME_ID } from './gating';
import { buildWsmDiscoveredTool, registerWsmDiscoveredTool, WSM_TOOL_ID } from './discoveredTool';

const EXE_PATH = path.join('C:', 'fake', 'tool', 'WitcherScriptMerger.Headless.exe');

describe('WSM_TOOL_ID', () => {
it('does not collide with game-witcher3\'s own built-in tool ID', () => {
// See docs/vortex-extension-design.md section 0: game-witcher3 already registers
// 'W3ScriptMerger'. There is no API to hide/replace another extension's tool
// registration, so this must be a distinct ID.
expect(WSM_TOOL_ID).not.toBe('W3ScriptMerger');
});
});

describe('buildWsmDiscoveredTool', () => {
it('builds a tool pointing at the given exe path, custom and visible', () => {
const tool = buildWsmDiscoveredTool({ exePath: EXE_PATH });

expect(tool.id).toBe(WSM_TOOL_ID);
expect(tool.path).toBe(EXE_PATH);
expect(tool.custom).toBe(true);
expect(tool.hidden).toBe(false);
expect(tool.requiredFiles).toEqual([]);
expect(tool.workingDirectory).toBe(path.dirname(EXE_PATH));
expect(tool.executable()).toBe('WitcherScriptMerger.Headless.exe');
});

it('defaults environment to an empty object when none is given', () => {
const tool = buildWsmDiscoveredTool({ exePath: EXE_PATH });
expect(tool.environment).toEqual({});
});

it('carries through a supplied environment map unchanged', () => {
const tool = buildWsmDiscoveredTool({ exePath: EXE_PATH, environment: { WSM_ModsDirectory: 'C:\\Mods' } });
expect(tool.environment).toEqual({ WSM_ModsDirectory: 'C:\\Mods' });
});

it('round-trips every field except the known-non-serializable executable function through JSON', () => {
// ITool.executable is typed as a function - it cannot survive JSON.stringify, a
// known, documented, unavoidable limitation shared with game-witcher3's own
// W3ScriptMerger registration (see this module's own doc comment). Every other
// field must survive, since Vortex persists discovered-tools state to disk.
const tool = buildWsmDiscoveredTool({ exePath: EXE_PATH, environment: { WSM_ModsDirectory: 'C:\\Mods' } });
const roundTripped = JSON.parse(JSON.stringify(tool)) as Record<string, unknown>;

const expectedWithoutExecutable: Record<string, unknown> = { ...tool };
delete expectedWithoutExecutable.executable;
expect(roundTripped).toEqual(expectedWithoutExecutable);
});
});

describe('registerWsmDiscoveredTool', () => {
it('dispatches addDiscoveredTool for witcher3 with the given tool, marked custom/manual', () => {
const dispatch = vi.fn();
const api = { store: { dispatch } } as unknown as Parameters<typeof registerWsmDiscoveredTool>[0];
const tool = buildWsmDiscoveredTool({ exePath: EXE_PATH });

registerWsmDiscoveredTool(api, tool);

expect(dispatch).toHaveBeenCalledTimes(1);
const dispatchedAction = dispatch.mock.calls[0][0] as { payload: { gameId: string; toolId: string; result: unknown; manual: boolean } };
expect(dispatchedAction.payload.gameId).toBe(WITCHER3_GAME_ID);
expect(dispatchedAction.payload.toolId).toBe(WSM_TOOL_ID);
expect(dispatchedAction.payload.result).toBe(tool);
expect(dispatchedAction.payload.manual).toBe(true);
});

it('throws rather than silently no-oping when api.store is unavailable', () => {
// Callers (toolAcquisition.ts's ensureWsmToolRegistered/acquireWsmTool) treat this
// function completing without throwing as proof the tool was actually registered -
// a silent no-op here would make them report success with nothing really dispatched.
const api = { store: undefined } as unknown as Parameters<typeof registerWsmDiscoveredTool>[0];
const tool = buildWsmDiscoveredTool({ exePath: EXE_PATH });

expect(() => registerWsmDiscoveredTool(api, tool)).toThrow(/store is unavailable/);
});
});
99 changes: 99 additions & 0 deletions vortex-extension/src/discoveredTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import * as path from 'path';
import { actions, types } from 'vortex-api';
import { WITCHER3_GAME_ID } from './gating';

/**
* There is no `context.registerTool` API in `vortex-api` (re-confirmed against
* `lib/api.d.ts` - `docs/vortex-extension-design.md` section 1) - tool discovery is
* always `actions.addDiscoveredTool(gameId, toolId, toolDetails, isCustom)`, dispatched
* via `api.store.dispatch(...)`, exactly like Vortex's own built-in `game-witcher3`
* extension already registers its `W3ScriptMerger` tool (`docs/vortex-extension-design.md`
* section 0).
*
* Deliberately a **different** tool ID from that one - `W3ScriptMerger` belongs to
* `game-witcher3`, a separate extension this one is a companion to, not a replacement
* of (see `gating.ts`'s own doc comment). There is no API to hide/disable another
* extension's existing tool registration, so both tools coexist in Vortex's Tools
* dashboard: `game-witcher3`'s `W3ScriptMerger` (which downloads and launches the GUI of
* a different, older WSM fork - `IDCs/WitcherScriptMerger` - per the design doc's
* research) and this one, clearly and distinctly labeled, pointing at a build of *this*
* repo instead.
*/
export const WSM_TOOL_ID = 'WitcherScriptMergerEnhanced';

export interface WsmDiscoveredToolOptions {
/** Absolute path to the acquired `WitcherScriptMerger.Headless.exe`. */
exePath: string;
/**
* `WSM_<KeyName>` environment-variable overrides (see `wsmEnv.ts`'s `buildWsmEnv`) to
* attach to this tool's registration, applied by Vortex if the user launches it
* manually from the Tools dashboard. This is a secondary use of `buildWsmEnv`'s
* output - the primary one, per this unit's own instructions, is passing it straight
* into a spawned child process's `env` (`mcpClient.ts`'s `WsmMcpClientOptions.env`,
* demonstrated in `test/toolAcquisition.integration.test.ts`), not this static field.
*/
environment?: Record<string, string>;
}

/**
* Builds the `IDiscoveredTool` object `registerWsmDiscoveredTool` dispatches.
*
* **Known, unavoidable serialization caveat, not unique to this extension:**
* `ITool.executable` (which `IDiscoveredTool` inherits) is typed as a function
* (`(discoveredPath?: string) => string`), and Vortex's discovered-tools state is
* ordinarily persisted to disk across restarts. A function cannot survive a
* `JSON.stringify` round-trip - the same shape `game-witcher3`'s own `scriptmerger.ts`
* uses in production for `W3ScriptMerger` (per the design doc's direct source review),
* so this isn't a novel risk this unit introduces, just an inherited one. Untested here
* against real Vortex persistence (no real Vortex host in this repo's test setup) -
* `discoveredTool.test.ts` instead asserts every *other* field round-trips through
* `JSON.parse(JSON.stringify(...))` correctly, and re-registration happens on every
* `index.ts` `context.once` regardless (see that file), which would paper over a stale
* persisted `executable` field even if persistence does drop it.
*/
export function buildWsmDiscoveredTool(options: WsmDiscoveredToolOptions): types.IDiscoveredTool {
return {
id: WSM_TOOL_ID,
name: 'WitcherScriptMerger (Enhanced)',
shortName: 'WSM+',
// We already know the exact acquired path - no on-disk discovery scan needed, so
// requiredFiles (which drives that scan) is deliberately empty.
requiredFiles: [],
executable: () => path.basename(options.exePath),
// No default `parameters`: WitcherScriptMerger.Headless.exe with no args prints
// usage and exits 1 rather than doing anything useful (see
// WitcherScriptMerger.Headless/CLAUDE.md's routing section) - there's no verb that's
// meaningfully "the default" for a human clicking this tile in Vortex's Tools
// dashboard (`mcp` mode just sits waiting for JSON-RPC on stdin, which looks hung to
// a human; `merge` needs GameDirectory/ModsDirectory already configured). Documented
// limitation, not an oversight - a later unit driving this programmatically
// (mcpClient.ts, or a future one-shot `merge` CLI invocation) always passes its own
// explicit `args`, bypassing this default entirely.
environment: options.environment ?? {},
path: options.exePath,
hidden: false,
custom: true,
workingDirectory: path.dirname(options.exePath),
};
}

/**
* Dispatches `actions.addDiscoveredTool` for Witcher 3 specifically - this extension
* never registers a tool for any other game.
*
* `IExtensionApi.store` is typed optional, but this extension only ever calls this from
* inside `index.ts`'s `context.once` (or code reachable from it), by which point Vortex
* guarantees a real store exists - a missing store there would be a genuine, unexpected
* problem, not a normal condition to swallow. Throwing here (rather than the previous
* `api.store?.dispatch(...)`, a silent no-op) matters concretely: `ensureWsmToolRegistered`/
* `acquireWsmTool` (`toolAcquisition.ts`) both treat this call completing without
* throwing as "the tool is now registered" and return `true` accordingly - a swallowed
* no-op here would make both of those report success while nothing was actually
* dispatched to the Redux store.
*/
export function registerWsmDiscoveredTool(api: types.IExtensionApi, tool: types.IDiscoveredTool): void {
if (!api.store) {
throw new Error('Cannot register the WSM discovered tool: api.store is unavailable.');
}
api.store.dispatch(actions.addDiscoveredTool(WITCHER3_GAME_ID, WSM_TOOL_ID, tool, true));
}
Loading
Loading