diff --git a/vortex-extension/package.json b/vortex-extension/package.json index b7413dd..e73fafc 100644 --- a/vortex-extension/package.json +++ b/vortex-extension/package.json @@ -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", diff --git a/vortex-extension/src/index.test.ts b/vortex-extension/src/index.test.ts index 0c6f80b..8dfeb33 100644 --- a/vortex-extension/src/index.test.ts +++ b/vortex-extension/src/index.test.ts @@ -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` @@ -57,6 +64,10 @@ function fakeContext(initialActiveGameId: string | undefined, profiles: Record 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: { diff --git a/vortex-extension/src/index.ts b/vortex-extension/src/index.ts index 6f79e43..93bf61f 100644 --- a/vortex-extension/src/index.ts +++ b/vortex-extension/src/index.ts @@ -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'; /** @@ -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 @@ -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); diff --git a/vortex-extension/src/mergeHistoryDashlet.test.ts b/vortex-extension/src/mergeHistoryDashlet.test.ts new file mode 100644 index 0000000..3154c11 --- /dev/null +++ b/vortex-extension/src/mergeHistoryDashlet.test.ts @@ -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); + }); +}); diff --git a/vortex-extension/src/mergeHistoryDashlet.ts b/vortex-extension/src/mergeHistoryDashlet.ts new file mode 100644 index 0000000..41870bd --- /dev/null +++ b/vortex-extension/src/mergeHistoryDashlet.ts @@ -0,0 +1,238 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as React from 'react'; +import { Dashlet, types } from 'vortex-api'; +import { isWitcher3Active } from './gating'; +import { RecordedMerge, WsmMcpClient } from './mcpClient'; +import { getWsmToolDir } from './storage'; +import { WSM_HEADLESS_EXE_NAME } from './toolAcquisition'; + +/** + * Dashboard tile listing every merge already recorded in `MergeInventory.xml` (relative + * path, which mod folder holds the merged result, and each source mod's recorded hash) - + * the read-only "merge history" view from `docs/vortex-extension-design.md`'s section 5. + * + * **Data source (option (a) of this unit's two options): `WsmMcpClient.listMerges()`, + * not a direct `MergeInventory.xml` parse.** Two things pointed the same way: (1) + * `mcpClient.ts`'s own doc comment already names "a merge-history dashlet" as one of the + * intended per-workflow callers of `WsmMcpClient`, and (2) + * `docs/vortex-extension-design.md`'s section 5 explicitly recommends `list_merges` "for + * parity/simplicity" now that it exists, over this extension re-parsing the XML itself. + * Going through WSM's own MCP tool means this file never has to duplicate + * `WitcherScriptMerger.Core/Inventory/MergeInventory.cs`'s `XmlSerializer` schema + * (`RelativePath`/`MergedModName`/`IncludedMod[Hash]` element/attribute names, the + * `AddMissingHashes` auto-heal-on-load quirk, etc.) - that schema knowledge stays owned + * by the C# side, at the cost of spawning a short-lived WSM process per fetch instead of + * a plain file read. For a dashlet that only fetches on mount/manual refresh (not on a + * timer), that cost is a handful of times per Vortex session, not a hot path. + * + * **Process lifecycle**: one `WsmMcpClient` per fetch (initial mount, or a manual + * "Refresh" click), closed in a `finally` - never a long-lived singleton, per + * `mcpClient.ts`'s own documented policy ("spawn per user-initiated workflow, tear down + * when the caller is done with it"). This dashlet reads its own mount, and each of its + * own subsequent refreshes, as that workflow - there's no multi-step review session (like + * a future scan-then-merge panel) to amortize a handshake across here. + */ + +export interface MergeHistoryFetchDeps { + /** Test-only seam - defaults to the real `WsmMcpClient.connect`. */ + connect?: typeof WsmMcpClient.connect; +} + +export type MergeHistoryResult = + | { status: 'not-installed' } + | { status: 'error'; message: string } + | { status: 'loaded'; merges: RecordedMerge[] }; + +/** + * Absolute path to the acquired WSM Headless exe, or `null` if none has been acquired + * yet. Same computation `toolAcquisition.ts`'s own `ensureWsmToolRegistered` (and + * `acquireWsmToolUncoordinated`) uses (`getWsmToolDir(api)` + `WSM_HEADLESS_EXE_NAME`) - + * deliberately not read from Vortex's discovered-tools Redux state instead, since that + * state's own `executable` field has an unverified persistence story (see + * `discoveredTool.ts`'s own doc comment), while this plain filesystem check is exactly as + * reliable as the acquisition path that produced it. + * + * **Known duplication, not an oversight**: this two-line computation now exists in three + * places (here and `toolAcquisition.ts`'s two call sites). Not factored into a shared + * helper in `storage.ts`/`toolAcquisition.ts` because this unit's own scope keeps both of + * those files read-only ("beyond reading them" - see this unit's own task description); + * a later unit touching either file is better positioned to extract one. + */ +export function resolveWsmExePath(api: types.IExtensionApi): string | null { + const exePath = path.join(getWsmToolDir(api), WSM_HEADLESS_EXE_NAME); + return fs.existsSync(exePath) ? exePath : null; +} + +/** + * Fetches merge history via a short-lived `WsmMcpClient`: connect, `list_merges`, close - + * always closes, even when `listMerges()` itself throws, per this module's own "process + * lifecycle" doc comment above. Never throws itself - every failure (no acquired exe, + * connect failure, a tool-call error) becomes a `MergeHistoryResult` the caller can + * render directly. + */ +export async function fetchMergeHistory( + api: types.IExtensionApi, + deps: MergeHistoryFetchDeps = {}, +): Promise { + const exePath = resolveWsmExePath(api); + if (exePath === null) { + return { status: 'not-installed' }; + } + + const connect = deps.connect ?? WsmMcpClient.connect; + let client: WsmMcpClient | undefined; + try { + client = await connect({ exePath }); + const merges = await client.listMerges(); + return { status: 'loaded', merges }; + } catch (err) { + return { status: 'error', message: err instanceof Error ? err.message : String(err) }; + } finally { + if (client) { + await client.close(); + } + } +} + +interface MergeHistoryDashletProps { + api: types.IExtensionApi; +} + +type MergeHistoryDashletState = { status: 'loading' } | MergeHistoryResult; + +export class MergeHistoryDashlet extends React.Component { + private mounted = false; + + constructor(props: MergeHistoryDashletProps) { + super(props); + this.state = { status: 'loading' }; + this.handleRefreshClick = this.handleRefreshClick.bind(this); + } + + componentDidMount(): void { + this.mounted = true; + void this.load(); + } + + componentWillUnmount(): void { + this.mounted = false; + } + + private handleRefreshClick(): void { + void this.load(); + } + + private async load(): Promise { + this.setStateIfMounted({ status: 'loading' }); + const result = await fetchMergeHistory(this.props.api); + this.setStateIfMounted(result); + } + + private setStateIfMounted(state: MergeHistoryDashletState): void { + if (this.mounted) { + this.setState(state); + } + } + + render(): React.ReactElement { + return React.createElement( + Dashlet, + { className: 'wsm-merge-history-dashlet', title: 'WitcherScriptMerger History' }, + this.renderBody(), + ); + } + + private renderBody(): React.ReactElement { + const { state } = this; + + const refreshButton = React.createElement( + 'button', + { + type: 'button', + className: 'btn btn-default', + disabled: state.status === 'loading', + onClick: this.handleRefreshClick, + }, + 'Refresh', + ); + + return React.createElement('div', null, refreshButton, this.renderContent(state)); + } + + private renderContent(state: MergeHistoryDashletState): React.ReactElement { + switch (state.status) { + case 'loading': + return React.createElement('p', null, 'Loading merge history...'); + case 'not-installed': + return React.createElement( + 'p', + null, + 'WitcherScriptMerger has not been acquired yet - no merge history to show.', + ); + case 'error': + return React.createElement( + 'p', + { className: 'text-danger' }, + `Failed to load merge history: ${state.message}`, + ); + case 'loaded': + return this.renderMerges(state.merges); + default: { + // Exhaustiveness check: a new MergeHistoryDashletState member added without a + // matching case here fails `tsc`, not just at runtime. + const exhaustiveCheck: never = state; + throw new Error(`Unhandled merge history status: ${JSON.stringify(exhaustiveCheck)}`); + } + } + } + + private renderMerges(merges: RecordedMerge[]): React.ReactElement { + if (merges.length === 0) { + return React.createElement('p', null, 'No merges recorded yet.'); + } + + const rows = merges.map((merge) => + React.createElement( + 'li', + { key: merge.relativePath }, + React.createElement('strong', null, merge.relativePath), + ` → ${merge.mergedModName} (${merge.mods.map((mod) => `${mod.name} [${mod.hash}]`).join(', ')})`, + ), + ); + + return React.createElement('ul', { className: 'wsm-merge-history-list' }, ...rows); + } +} + +/** + * Registers the merge-history dashlet. Call this synchronously from `index.ts`'s + * `main()`, **not** from inside `context.once(...)`: despite `index.ts`'s own doc + * comment suggesting later `context.register*` calls belong inside `once`, + * `@nexusmods/vortex-api`'s own `IExtensionContext` doc comment + * (`node_modules/@nexusmods/vortex-api/lib/api.d.ts`, ~line 3578) is explicit that + * `once` "should be used for all your extension setup **except for the register + * calls**" - register calls are collected once, synchronously, while every extension's + * own `init`/`main` runs, before `once` ever fires. (The tool-acquisition unit's own use + * of `once` for `tryRegisterWsmTool` is a different, legitimate case: that function + * dispatches a Redux action via `api.store`, which needs the store to exist - a real + * `once`-shaped requirement, not a register call.) + * + * Gated on `isWitcher3Active` via the live `isVisible` callback (matches `gating.ts`'s + * own documented preference for a live `condition`/`isVisible` over a load-time-only + * check) - ignores the `state` argument Vortex passes into `isVisible` and re-derives it + * from `context.api.getState()` instead, so this reuses `gating.ts`'s helper directly + * rather than duplicating its `selectors.activeGameId` call. + */ +export function registerMergeHistoryDashlet(context: types.IExtensionContext): void { + context.registerDashlet( + 'WitcherScriptMerger History', + 2, + 2, + 250, + MergeHistoryDashlet, + () => isWitcher3Active(context.api), + () => ({ api: context.api }), + { closable: true }, + ); +} diff --git a/vortex-extension/test/mergeHistory.integration.test.ts b/vortex-extension/test/mergeHistory.integration.test.ts new file mode 100644 index 0000000..f717e72 --- /dev/null +++ b/vortex-extension/test/mergeHistory.integration.test.ts @@ -0,0 +1,184 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { fetchMergeHistory } from '../src/mergeHistoryDashlet'; +import { getWsmToolDir } from '../src/storage'; + +// Real, end-to-end integration test for fetchMergeHistory (src/mergeHistoryDashlet.ts): +// spawns the actual, compiled WitcherScriptMerger Headless host's `mcp` verb and drives +// it through a full connect -> list_merges -> close round trip, the same way +// mcpClient.integration.test.ts proves the lower-level WsmMcpClient itself. That existing +// test only ever asserts list_merges returns `[]` against an empty mods folder - not +// enough to prove this file's own mapping of a *populated* MergeInventory.xml into a +// MergeHistoryResult, since MergeInventory.Load's bare `catch { inventory = new +// MergeInventory(); }` (WitcherScriptMerger.Core/Inventory/MergeInventory.cs) means a +// malformed fixture would silently also produce `[]` - indistinguishable from "no merges" +// unless a test actually asserts non-empty, field-matched output. This test writes a +// scratch MergeInventory.xml by hand (schema confirmed directly against +// WitcherScriptMerger.Core/Inventory/MergeInventory.cs, Merge.cs, ModFile.cs, FileHash.cs +// - not guessed) specifically to close that gap. + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..', '..'); +const HEADLESS_CSPROJ = path.join( + REPO_ROOT, + 'WitcherScriptMerger.Headless', + 'WitcherScriptMerger.Headless.csproj', +); +const HEADLESS_BUILD_DIR = path.join( + REPO_ROOT, + 'WitcherScriptMerger.Headless', + 'bin', + 'Debug', + 'net10.0', +); +const HEADLESS_EXE = path.join(HEADLESS_BUILD_DIR, 'WitcherScriptMerger.Headless.exe'); + +// Same escaping rationale as mcpClient.integration.test.ts's own helper of the same name +// - kept local rather than shared/exported since it's test-fixture plumbing, not +// extension code. +function escapeXmlAttribute(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function escapeXmlText(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>'); +} + +function buildScratchConfig(modsDirectory: string): string { + // Mirrors WitcherScriptMerger.Headless/App.config's shape, same as + // mcpClient.integration.test.ts's own buildScratchConfig. + return ` + + + + + + + + + + + + + + + + + + +`; +} + +// Schema confirmed directly against WitcherScriptMerger.Core/Inventory/MergeInventory.cs +// (root element defaults to the class name, "MergeInventory"; [XmlElement("Merge")] names +// each item), Merge.cs ([XmlElement] MergedModName), ModFile.cs ([XmlElement] RelativePath, +// [XmlElement("IncludedMod")] Mods), and FileHash.cs ([XmlAttribute] Hash, [XmlText] Name - +// so each mod is `ModName`, name as element text, not +// an attribute or child element). Real, non-null Hash values are supplied deliberately - +// MergeInventory.Load's AddMissingHashes back-fills (and can Save()) any null Hash by +// recomputing it from a real mod file on disk, which this fixture has none of. +function buildScratchInventoryXml(relativePath: string, mergedModName: string, mods: Array<{ name: string; hash: string }>): string { + const modElements = mods + .map((mod) => ` ${escapeXmlText(mod.name)}`) + .join('\n'); + return ` + + + ${escapeXmlText(relativePath)} + ${escapeXmlText(mergedModName)} +${modElements} + + +`; +} + +let userDataDir: string; +let toolDir: string; +let fakeApi: Parameters[0]; + +const RELATIVE_PATH = 'content\\scripts\\game\\r4Game.ws'; +const MERGED_MOD_NAME = 'mod0000_MergedFiles'; +const MOD_ALPHA = { name: 'modAlpha', hash: '1a2b3c4d' }; +const MOD_BETA = { name: 'modBeta', hash: '5e6f7089' }; + +beforeAll(() => { + if (!fs.existsSync(HEADLESS_EXE)) { + const result = spawnSync('dotnet', ['build', HEADLESS_CSPROJ, '-c', 'Debug'], { + cwd: REPO_ROOT, + stdio: 'inherit', + }); + if (result.status !== 0) { + throw new Error( + `dotnet build of WitcherScriptMerger.Headless failed (required to run the mergeHistory ` + + `integration test) - exit code ${result.status}`, + ); + } + } + + if (!fs.existsSync(HEADLESS_EXE)) { + throw new Error(`Expected built exe not found at ${HEADLESS_EXE} even after building.`); + } + + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wsm-vortex-history-integration-')); + fakeApi = { + getPath: (name: string) => (name === 'userData' ? userDataDir : `/unexpected/${name}`), + } as unknown as Parameters[0]; + + // Lay out a scratch "already acquired" install at the exact location + // resolveWsmExePath (src/mergeHistoryDashlet.ts) / getWsmToolDir (src/storage.ts) + // expect, mirroring toolAcquisition.integration.test.ts's own approach. + toolDir = getWsmToolDir(fakeApi); + fs.mkdirSync(toolDir, { recursive: true }); + fs.cpSync(HEADLESS_BUILD_DIR, toolDir, { recursive: true }); + + const modsDir = path.join(toolDir, 'Mods'); + fs.mkdirSync(modsDir, { recursive: true }); + fs.writeFileSync( + path.join(toolDir, 'WitcherScriptMerger.Headless.dll.config'), + buildScratchConfig(modsDir), + 'utf8', + ); + + // Paths.Inventory ("MergeInventory.xml") is a relative path resolved against + // Environment.CurrentDirectory, which the Headless host pins to AppContext.BaseDirectory + // (the exe's own directory) before dispatching to `mcp` mode (see + // WitcherScriptMerger.Core/Mcp/CLAUDE.md) - so this has to sit next to the exe, i.e. + // directly inside toolDir, not inside modsDir. + fs.writeFileSync( + path.join(toolDir, 'MergeInventory.xml'), + buildScratchInventoryXml(RELATIVE_PATH, MERGED_MOD_NAME, [MOD_ALPHA, MOD_BETA]), + 'utf8', + ); +}, 300_000); + +afterAll(() => { + if (userDataDir) { + fs.rmSync(userDataDir, { recursive: true, force: true }); + } +}); + +describe('fetchMergeHistory integration (real WSM Headless process, populated MergeInventory.xml)', () => { + it('returns the recorded merge, with matching relative path, merged-mod name, and per-mod hashes', async () => { + const result = await fetchMergeHistory(fakeApi); + + expect(result.status).toBe('loaded'); + if (result.status !== 'loaded') { + return; + } + + expect(result.merges).toHaveLength(1); + const [merge] = result.merges; + expect(merge.relativePath).toBe(RELATIVE_PATH); + expect(merge.mergedModName).toBe(MERGED_MOD_NAME); + expect(merge.mods).toEqual([MOD_ALPHA, MOD_BETA]); + }, 30_000); +}); diff --git a/vortex-extension/test/testUtils/vortexApiStub.ts b/vortex-extension/test/testUtils/vortexApiStub.ts index 9d9ef02..ec76c5e 100644 --- a/vortex-extension/test/testUtils/vortexApiStub.ts +++ b/vortex-extension/test/testUtils/vortexApiStub.ts @@ -72,3 +72,17 @@ export const util = { export const log = (_level: string, _message: string, _metadata?: unknown): void => { // Intentionally a no-op in tests - nothing here asserts on log output. }; + +// `Dashlet` needs a real (if trivial) implementation, not just a type - added alongside +// mergeHistoryDashlet.ts, which imports it as a value and passes it to +// `React.createElement` inside its own `MergeHistoryDashlet.render()`. Under real ESM +// semantics, importing a named binding a module doesn't actually export is a hard error +// at import time, even if the binding is never called at runtime - and +// mergeHistoryDashlet.test.ts's own module-level import of mergeHistoryDashlet.ts +// reaches this import whether or not any test actually renders the component (it +// doesn't - see that test file's own comment on why: no jsdom in vitest.config.ts). +// Same "real (if simplified) implementation, not type-only" reasoning as +// `actions`/`util`/`log` above. A plain function returning its own `children` prop is +// good enough here - nothing in this repo's tests ever mounts/renders a Dashlet. +export const Dashlet = (props: { className?: string; title?: string; children?: unknown }): unknown => + props.children ?? null;