From d4cd98b08ee8a8a5981f011bf38da28a91a5c223 Mon Sep 17 00:00:00 2001 From: Chris Knight Date: Mon, 10 Aug 2026 16:00:24 -0400 Subject: [PATCH] Add "Resolve Script Conflicts" action and merge panel to the Vortex extension. Fix the stale MergeConflictsResult interface in mcpClient.ts (missing functionLevelDecisions, the function-level merge fallback's audit trail) and add a new registerAction entry that previews a merge via mergeConflicts({dryRun: true}), shows merged/skipped counts and the function-level decisions prominently in a Markdown dialog, and runs the real merge on confirmation via a second, freshly-spawned WsmMcpClient. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah --- vortex-extension/src/index.test.ts | 39 ++- vortex-extension/src/index.ts | 27 +- vortex-extension/src/mcpClient.ts | 12 + vortex-extension/src/mergePanel.test.ts | 132 ++++++++++ vortex-extension/src/mergePanel.ts | 155 +++++++++++ vortex-extension/src/resolveAction.test.ts | 243 +++++++++++++++++ vortex-extension/src/resolveAction.ts | 244 ++++++++++++++++++ .../test/mcpClient.integration.test.ts | 201 +++++++++++++++ 8 files changed, 1045 insertions(+), 8 deletions(-) create mode 100644 vortex-extension/src/mergePanel.test.ts create mode 100644 vortex-extension/src/mergePanel.ts create mode 100644 vortex-extension/src/resolveAction.test.ts create mode 100644 vortex-extension/src/resolveAction.ts diff --git a/vortex-extension/src/index.test.ts b/vortex-extension/src/index.test.ts index 84f1454..7922888 100644 --- a/vortex-extension/src/index.test.ts +++ b/vortex-extension/src/index.test.ts @@ -16,17 +16,23 @@ 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), matching gating.test.ts's own - * fakeApi philosophy: a simplified fake, not a replica of Vortex's real context shape. */ + * logic (context.once, context.api.getState/events.on, context.registerAction - the + * last one added alongside resolveAction.ts's registration, called directly at + * `main()` time per IExtensionContext.once's own doc comment - see index.ts's own + * updated header comment for why that's NOT deferred through context.once), matching + * gating.test.ts's own fakeApi philosophy: a simplified fake, not a replica of + * Vortex's real context shape. */ function fakeContext(initialActiveGameId: string | undefined) { const state = { activeGameId: initialActiveGameId }; let onceCallback: (() => void) | undefined; const eventListeners = new Map void>>(); + const registerActionMock = vi.fn(); const context = { once: (callback: () => void) => { onceCallback = callback; }, + registerAction: registerActionMock, api: { getState: () => state, events: { @@ -46,6 +52,7 @@ function fakeContext(initialActiveGameId: string | undefined) { setActiveGame: (gameId: string | undefined) => { state.activeGameId = gameId; }, + registerActionMock, }; } @@ -115,4 +122,32 @@ describe('main (index.ts)', () => { // Let the rejected promise's .catch() handler actually run before the test ends. await new Promise((resolve) => setTimeout(resolve, 0)); }); + + it('registers the "Resolve Script Conflicts" action directly (not deferred through context.once), gated live on witcher3 being active', () => { + const { context, registerActionMock, setActiveGame } = fakeContext('skyrimse'); + + main(context); + + // Registered synchronously by main() itself - IExtensionContext.once's own doc + // comment says registrations are expected to have already happened by the time + // `once` fires, so this must not require fireOnce() to have been called at all. + expect(registerActionMock).toHaveBeenCalledTimes(1); + const [group, , , , title, , condition] = registerActionMock.mock.calls[0] as [ + string, + number, + string, + unknown, + string, + unknown, + () => boolean, + ]; + expect(group).toBe('mod-icons'); + expect(title).toBe('Resolve Script Conflicts'); + + // The condition callback is live, re-evaluated against current state each time + // Vortex calls it - not baked in once at registration time. + expect(condition()).toBe(false); + setActiveGame(WITCHER3_GAME_ID); + expect(condition()).toBe(true); + }); }); diff --git a/vortex-extension/src/index.ts b/vortex-extension/src/index.ts index 773c254..175f68c 100644 --- a/vortex-extension/src/index.ts +++ b/vortex-extension/src/index.ts @@ -1,5 +1,6 @@ import { log, types } from 'vortex-api'; import { isWitcher3Active } from './gating'; +import { registerResolveScriptConflictsAction } from './resolveAction'; import { ensureWsmToolRegistered } from './toolAcquisition'; /** @@ -23,12 +24,20 @@ import { ensureWsmToolRegistered } from './toolAcquisition'; * and without this, registering a previously-acquired tool would only ever happen if * Witcher 3 already happened to be active the moment Vortex loaded this extension. * - * Later units (conflict scanning, 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. + * Later units (conflict scanning, dashlets) each add their own `context.register*` + * calls directly in `main`, alongside `registerResolveScriptConflictsAction` below - + * NOT inside the `context.once(...)` callback: `IExtensionContext.once`'s own doc + * comment (`lib/api.d.ts`) says registration calls are expected to have already + * happened by the time `once` fires ("if your extension registers its own extension + * function... those registrations happen before once is called"), matching every + * `registerAction` call site found in Vortex's own extensions (`gh search code + * 'registerAction("mod-icons"' --repo Nexus-Mods/Vortex`) - none of them defer through + * `once`. Each registration instead gates on `isWitcher3Active` (imported from + * `./gating`) via its own `condition` callback, so a live game-mode switch is honored + * without requiring a Vortex restart - see `resolveAction.ts`'s own doc comment for how + * this unit's own action does exactly that, the declarative counterpart to how + * `tryRegisterWsmTool` below re-checks the same condition imperatively on every + * `'gamemode-activated'` event. * * This extension must never call `context.registerGame('witcher3', ...)` - Vortex's own * built-in `game-witcher3` extension already owns that registration; this extension is a @@ -68,6 +77,12 @@ function main(context: types.IExtensionContext): boolean { context.api.events.on('gamemode-activated', tryRegisterWsmTool); }); + // This unit's own registration - see resolveAction.ts's own doc comment for why it + // gates on Witcher 3 being active via a live `condition` callback rather than an + // upfront check here, the same pattern every other registration in this extension + // follows (gating.ts's own doc comment). + registerResolveScriptConflictsAction(context); + return true; } diff --git a/vortex-extension/src/mcpClient.ts b/vortex-extension/src/mcpClient.ts index 0807424..de465e7 100644 --- a/vortex-extension/src/mcpClient.ts +++ b/vortex-extension/src/mcpClient.ts @@ -95,6 +95,18 @@ export interface MergeConflictsResult { skipped: string[]; unmatched: string[]; dryRun: boolean; + /** + * Human-readable audit-trail lines from the function-level merge fallback (splits a + * conflicting `.ws` file into individual functions and resolves each independently + * when the whole-file merge can't) - see `WsmMcpTools.MergeConflicts`'s own + * description (`WitcherScriptMerger.Core/Mcp/WsmMcpTools.cs`) and + * `FileMerger.HeadlessMergeSummary.FunctionLevelDecisions` + * (`WitcherScriptMerger.Core/Inventory/FileMerger.cs`, a `List` server-side - + * confirmed directly against both files rather than assumed). Empty when no conflict + * needed the fallback. Genuinely useful to a user, not just noise - always worth + * surfacing, not just the merged/skipped counts. + */ + functionLevelDecisions: string[]; } export interface GetStatusResult { diff --git a/vortex-extension/src/mergePanel.test.ts b/vortex-extension/src/mergePanel.test.ts new file mode 100644 index 0000000..9613694 --- /dev/null +++ b/vortex-extension/src/mergePanel.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; +import { MergeConflictsResult } from './mcpClient'; +import { buildMergeSummaryDialogContent } from './mergePanel'; + +function result(overrides: Partial = {}): MergeConflictsResult { + return { + merged: [], + skipped: [], + unmatched: [], + dryRun: false, + functionLevelDecisions: [], + ...overrides, + }; +} + +describe('buildMergeSummaryDialogContent', () => { + it('renders merged/skipped counts in the markdown body', () => { + const content = buildMergeSummaryDialogContent( + result({ merged: ['game\\a.ws'], skipped: ['game\\b.ws', 'game\\c.xml'] }), + { isPreview: false }, + ); + + expect(content.md).toContain('**1** file(s) merged automatically'); + expect(content.md).toContain('**2** file(s) need manual review'); + }); + + it('uses preview-tense wording ("would merge"/"would need") and a preview banner when isPreview is true', () => { + const content = buildMergeSummaryDialogContent(result({ merged: ['a.ws'] }), { isPreview: true }); + + expect(content.md).toContain('Preview only'); + expect(content.md).toContain('would merge automatically'); + }); + + it('uses non-preview wording and no preview banner when isPreview is false', () => { + const content = buildMergeSummaryDialogContent(result({ merged: ['a.ws'] }), { isPreview: false }); + + expect(content.md).not.toContain('Preview only'); + expect(content.md).toContain('merged automatically'); + expect(content.md).not.toContain('would merge automatically'); + }); + + it('surfaces functionLevelDecisions prominently - before the plain merged/skipped path lists, not buried', () => { + const decisions = [ + "game\\actor.ws: function OnTakeDamage: kept modX's version (9 changed diff blocks vs. vanilla...), discarded modY's conflicting change to this function.", + ]; + const content = buildMergeSummaryDialogContent( + result({ merged: ['game\\actor.ws'], functionLevelDecisions: decisions }), + { isPreview: false }, + ); + + expect(content.md).toContain('Function-level merge decisions'); + expect(content.md).toContain('kept modX'); + + const decisionsIndex = content.md!.indexOf('Function-level merge decisions'); + const mergedListIndex = content.md!.indexOf('### Merged'); + expect(decisionsIndex).toBeGreaterThan(-1); + expect(mergedListIndex).toBeGreaterThan(-1); + expect(decisionsIndex).toBeLessThan(mergedListIndex); + }); + + it('omits the "Function-level merge decisions" section entirely when there are none', () => { + const content = buildMergeSummaryDialogContent(result({ merged: ['a.ws'] }), { isPreview: false }); + + expect(content.md).not.toContain('Function-level merge decisions'); + }); + + it('tells the user conflict-marker sidecars were written and opened for a real (non-preview) run with skipped files', () => { + const content = buildMergeSummaryDialogContent(result({ skipped: ['gameplay\\items.xml'] }), { + isPreview: false, + }); + + expect(content.md).toContain('DiffPlexConflicts'); + expect(content.md).toContain('opened for review'); + expect(content.md).toContain('items.xml'); + }); + + it('does not claim anything was opened for a preview (dry-run) run with skipped files', () => { + const content = buildMergeSummaryDialogContent(result({ skipped: ['gameplay\\items.xml'] }), { + isPreview: true, + }); + + expect(content.md).not.toContain('opened for review'); + expect(content.md).toContain('nothing is written until you confirm'); + }); + + it('lists unmatched paths when present', () => { + const content = buildMergeSummaryDialogContent(result({ unmatched: ['no\\such\\file.ws'] }), { + isPreview: false, + }); + + expect(content.md).toContain('Unmatched paths'); + expect(content.md).toContain('no\\such\\file.ws'); + }); + + it('omits the unmatched section entirely when there are no unmatched paths', () => { + const content = buildMergeSummaryDialogContent(result(), { isPreview: false }); + + expect(content.md).not.toContain('Unmatched paths'); + }); + + it('does not escape Markdown-significant characters in file paths - they are already inside a code span, and CommonMark code spans do not process backslash escapes', () => { + // Regression test: an earlier version of buildMergeSummaryDialogContent escaped + // paths *and* wrapped them in backticks, which - per CommonMark ("Backslash + // escapes do not work in ... code spans") - rendered the backslashes themselves + // instead of suppressing anything, corrupting the extension's own default + // merged-mod-name pattern ("mod0000_MergedFiles") into a stray-backslash mess. + // Caught in code review; this test now asserts the correct, literal rendering. + const content = buildMergeSummaryDialogContent(result({ merged: ['mod0000_MergedFiles\\a_b.ws'] }), { + isPreview: false, + }); + + expect(content.md).toContain('`mod0000_MergedFiles\\a_b.ws`'); + expect(content.md).not.toContain('\\_'); + }); + + it('escapes Markdown-significant characters in function-level decision text (plain prose, not a code span)', () => { + const content = buildMergeSummaryDialogContent( + result({ functionLevelDecisions: ["mod0000_MergedFiles: kept modX's edit"] }), + { isPreview: false }, + ); + + expect(content.md).toContain('mod0000\\_MergedFiles'); + }); + + it('treats a missing functionLevelDecisions field as "no decisions" instead of throwing - defends against an older WSM binary whose response predates this field', () => { + const malformed = { merged: ['a.ws'], skipped: [], unmatched: [], dryRun: false } as unknown as MergeConflictsResult; + + expect(() => buildMergeSummaryDialogContent(malformed, { isPreview: false })).not.toThrow(); + const content = buildMergeSummaryDialogContent(malformed, { isPreview: false }); + expect(content.md).not.toContain('Function-level merge decisions'); + }); +}); diff --git a/vortex-extension/src/mergePanel.ts b/vortex-extension/src/mergePanel.ts new file mode 100644 index 0000000..e2d2751 --- /dev/null +++ b/vortex-extension/src/mergePanel.ts @@ -0,0 +1,155 @@ +import { types } from 'vortex-api'; +import { MergeConflictsResult } from './mcpClient'; + +/** + * Dialog-content builders for the "Resolve Script Conflicts" action's two dialogs (a + * dry-run preview, then the real merge's result) - kept separate from + * `resolveAction.ts`'s orchestration so the *content* of what gets shown is a plain, + * synchronous, `WsmMcpClient`-free function this file's own unit tests can exercise + * directly, without spawning any process or touching Vortex's real dialog system. + * + * Deliberately built on `vortex-api`'s own `IDialogContent`/`api.showDialog` (a plain- + * data dialog Vortex itself renders generically, including a Markdown `md` field, per + * `lib/api.d.ts`) rather than a custom React component (`MergePanel.tsx`, one example + * filename this unit's own task description suggested): this project's TypeScript + * toolchain has no JSX support wired up yet, confirmed directly rather than assumed - + * `tsconfig.json` sets no `jsx` compiler option, `package.json` has no `@types/react` + * dependency, and `node_modules/react` (present only indirectly, as a peer dependency + * `@nexusmods/vortex-api` itself declares) ships no bundled TypeScript declarations of + * its own. Standing up a whole React/JSX toolchain (a `jsx` pragma, `@types/react` + * pinned to the exact React 16.14.0 Vortex itself ships, ...) is bigger, separate scope + * from this unit's actual job - preview, confirm, and surface `functionLevelDecisions` + * prominently - and `IDialogContent`'s `md` field already renders Markdown natively, so + * a headed, bulleted audit trail (exactly the "here's what happened and why" shape this + * feature needs) doesn't need a hand-rolled component to look right. If a later unit + * needs genuine interactive controls inside the dialog (e.g. per-file checkboxes + * driving `relativePaths`/`orderOverrides`), that's the point where standing up JSX + * support for real becomes worth it - not before; see this unit's own PR description + * for this same call, made explicitly rather than silently. + */ + +export interface MergeSummaryDialogOptions { + /** + * True for the dry-run preview dialog, false for the real-merge result dialog - + * changes the heading and the skipped-files wording. A dry run never opens + * conflict-marker sidecars (`DiffPlexMergeEngine.MergeHeadless`'s + * `openConflictMarkers` parameter, threaded from `FileMerger.MergeTextHeadless`'s + * `openConflictMarkers: !dryRun`), so only the non-dry-run wording can honestly say + * they were opened for review. + */ + isPreview: boolean; +} + +// Escapes characters that could otherwise be misread as Markdown syntax in *plain +// prose* text (the function-level decision lines below - the only caller of this +// function) - e.g. a decision line naming a mod called "mod0000_MergedFiles" would +// otherwise have its underscores rendered as emphasis. +// +// Deliberately NOT used for the backtick-wrapped file paths in pathListSection below, +// even though an earlier version of this file did exactly that: per CommonMark, +// "Backslash escapes do not work in ... code spans" - a code span's content is already +// rendered completely literally by definition, so escaping before wrapping in +// backticks doesn't suppress anything; it just makes the backslashes themselves show +// up in the rendered output (e.g. a path like "mod0000_MergedFiles\a_b.ws" would +// render as the literal text "mod0000\_MergedFiles\a\_b.ws", backslashes and all). +// Caught in this unit's own code review, confirmed against the CommonMark spec and +// fixed here - the regression test in mergePanel.test.ts previously asserted the +// broken, double-escaped output as if it were correct. +function escapeMarkdown(value: string): string { + return value.replace(/([*_`[\]])/g, '\\$1'); +} + +/** + * A heading + bullet list of file paths, each wrapped in a code span (backticks) - + * deliberately unescaped (see `escapeMarkdown`'s own comment above for why escaping + * and code-span-wrapping don't compose). `introText`, if given, is a line of prose + * between the heading and the list - shared by every call site below that needs one + * (the "Needs manual review" section's differing preview/real-run wording) so the + * heading/bullet-list shape and its code-span convention live in exactly one place. + */ +function pathListSection(heading: string, paths: string[], introText?: string): string[] { + if (paths.length === 0) { + return []; + } + const lines = ['', `### ${heading}`, '']; + if (introText !== undefined) { + lines.push(introText, ''); + } + lines.push(...paths.map((p) => `- \`${p}\``)); + return lines; +} + +/** + * Builds the `IDialogContent` for either the dry-run preview dialog or the real-merge + * result dialog - see this file's own header comment for why both share one builder + * rather than two near-duplicates (the only real differences are wording, not shape). + * + * Defensive against a `result` missing `functionLevelDecisions` even though the + * `MergeConflictsResult` type says it's always present: `WsmMcpClient.callTool` + * (`mcpClient.ts`) does a blind `JSON.parse(...) as T` with no runtime shape + * validation, and this extension's own `toolAcquisition.ts` never force-upgrades an + * already-acquired WSM binary - a user could plausibly still have one predating this + * field. Without this guard, reading `.length` off `undefined` would throw a + * `TypeError` from inside a `showDialog` argument expression in `resolveAction.ts`, + * escaping the try/catch there entirely and making the whole action appear to do + * nothing (caught only by the last-resort `.catch()` around the action's own + * callback, which just logs a warning) - caught in this unit's own code review. + * Treated as "no function-level decisions to report" rather than an error: version + * skew here is benign (the feature just doesn't have anything extra to show), not a + * failure worth interrupting the user over. + */ +export function buildMergeSummaryDialogContent( + result: MergeConflictsResult, + options: MergeSummaryDialogOptions, +): types.IDialogContent { + const { isPreview } = options; + const functionLevelDecisions = result.functionLevelDecisions ?? []; + const lines: string[] = []; + + if (isPreview) { + lines.push('**Preview only - no files have been changed yet.**', ''); + } + + const mergedVerb = isPreview ? 'would merge automatically' : 'merged automatically'; + lines.push(`- **${result.merged.length}** file(s) ${mergedVerb}`); + + const skippedVerb = isPreview ? 'would need manual review' : 'need manual review'; + lines.push(`- **${result.skipped.length}** file(s) ${skippedVerb}`); + + if (result.unmatched.length > 0) { + lines.push(`- **${result.unmatched.length}** requested path(s) no longer match a detected conflict`); + } + + // The itemized "here's exactly what happened and why" audit trail - surfaced + // prominently, right after the headline counts and before the plain merged/skipped + // path lists below, per this unit's own task description: worth showing, not burying + // under the counts. + if (functionLevelDecisions.length > 0) { + lines.push( + '', + '### Function-level merge decisions', + '', + "Some files couldn't be merged as a whole, but merged cleanly once split into individual functions:", + '', + ...functionLevelDecisions.map((decision) => `- ${escapeMarkdown(decision)}`), + ); + } + + lines.push(...pathListSection('Merged', result.merged)); + + lines.push( + ...pathListSection( + 'Needs manual review', + result.skipped, + isPreview + ? "No conflict-marker files are written for these yet - nothing is written until you confirm the merge below." + : 'Conflict-marker sidecar files were written to a `DiffPlexConflicts` folder ' + + "next to the WitcherScriptMerger executable, and opened for review in your " + + "system's default editor, for each of these:", + ), + ); + + lines.push(...pathListSection('Unmatched paths', result.unmatched)); + + return { md: lines.join('\n') }; +} diff --git a/vortex-extension/src/resolveAction.test.ts b/vortex-extension/src/resolveAction.test.ts new file mode 100644 index 0000000..e0358a4 --- /dev/null +++ b/vortex-extension/src/resolveAction.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it, vi } from 'vitest'; +import { WITCHER3_GAME_ID } from './gating'; +import { WSM_TOOL_ID } from './discoveredTool'; +import { MergeConflictsResult, WsmMcpClientOptions } from './mcpClient'; +import { resolveScriptConflicts, WsmMergeClient } from './resolveAction'; + +function mergeResult(overrides: Partial = {}): MergeConflictsResult { + return { + merged: [], + skipped: [], + unmatched: [], + dryRun: false, + functionLevelDecisions: [], + ...overrides, + }; +} + +interface FakeDialogResponse { + action: string; +} + +/** A minimal stand-in for IExtensionApi - just enough surface for + * resolveScriptConflicts's own logic (api.getState() fed through + * selectors.discoveryByGame, sendNotification, showErrorNotification, showDialog). + * Matches gating.test.ts/toolAcquisition.test.ts's own fakeApi philosophy: a + * simplified fake shaped to match test/testUtils/vortexApiStub.ts's simplified + * selectors, not a replica of Vortex's real Redux state. */ +function fakeApi(options: { + toolPath?: string; + toolEnvironment?: Record; + dialogResponses?: FakeDialogResponse[]; +} = {}) { + const state = { + discoveryByGame: + options.toolPath !== undefined + ? { + [WITCHER3_GAME_ID]: { + tools: { + [WSM_TOOL_ID]: { path: options.toolPath, environment: options.toolEnvironment ?? {} }, + }, + }, + } + : {}, + }; + + const dialogQueue = [...(options.dialogResponses ?? [])]; + const showDialogCalls: Array<{ type: string; title: string; content: unknown; actions: Array<{ label: string }> }> = []; + const notifications: unknown[] = []; + const errorNotifications: Array<{ message: string; detail: unknown }> = []; + + const api = { + getState: () => state, + sendNotification: vi.fn((notification: unknown) => { + notifications.push(notification); + return 'notification-id'; + }), + dismissNotification: vi.fn(), + showErrorNotification: vi.fn((message: string, detail: unknown) => { + errorNotifications.push({ message, detail }); + }), + showDialog: vi.fn(async (type: string, title: string, content: unknown, actions: Array<{ label: string }>) => { + showDialogCalls.push({ type, title, content, actions }); + const next = dialogQueue.shift(); + return { action: next?.action ?? actions[0]?.label ?? '', input: {} }; + }), + }; + + return { + api: api as unknown as Parameters[0], + showDialogCalls, + notifications, + errorNotifications, + }; +} + +/** A fake `connect` - returns queued results/errors in call order and records every + * `exePath`/`env` it was called with, plus how many of the clients it produced were + * closed - proves `resolveScriptConflicts` closes every client it opens, even on the + * error path (via `finally`). */ +function fakeConnect(outcomes: Array<{ result?: MergeConflictsResult; error?: Error }>) { + const calls: WsmMcpClientOptions[] = []; + let closedCount = 0; + let callIndex = 0; + + const connect = vi.fn(async (options: WsmMcpClientOptions): Promise => { + calls.push(options); + const outcome = outcomes[callIndex++]; + return { + mergeConflicts: async () => { + if (outcome.error) { + throw outcome.error; + } + return outcome.result!; + }, + close: async () => { + closedCount += 1; + }, + }; + }); + + return { connect, calls, closedCount: () => closedCount }; +} + +describe('resolveScriptConflicts', () => { + it('shows an error notification and never connects when no WSM tool has been registered', async () => { + const { api, notifications, showDialogCalls } = fakeApi({}); + const { connect } = fakeConnect([]); + + await resolveScriptConflicts(api, { connect }); + + expect(connect).not.toHaveBeenCalled(); + expect(showDialogCalls).toHaveLength(0); + expect(notifications).toHaveLength(1); + expect((notifications[0] as { type: string }).type).toBe('error'); + }); + + it('shows a "nothing to merge" dialog and stops, without a second connect, when the preview finds no conflicts', async () => { + const { api, showDialogCalls } = fakeApi({ toolPath: 'C:\\wsm\\WitcherScriptMerger.Headless.exe' }); + const { connect } = fakeConnect([{ result: mergeResult() }]); + + await resolveScriptConflicts(api, { connect }); + + expect(connect).toHaveBeenCalledTimes(1); + expect(showDialogCalls).toHaveLength(1); + expect(showDialogCalls[0].type).toBe('info'); + }); + + it('shows the preview dialog and does not run a real merge when the user cancels', async () => { + const { api, showDialogCalls } = fakeApi({ + toolPath: 'C:\\wsm\\WitcherScriptMerger.Headless.exe', + dialogResponses: [{ action: 'Cancel' }], + }); + const { connect } = fakeConnect([{ result: mergeResult({ merged: ['a.ws'], skipped: ['b.xml'] }) }]); + + await resolveScriptConflicts(api, { connect }); + + expect(connect).toHaveBeenCalledTimes(1); + expect(showDialogCalls).toHaveLength(1); + expect(showDialogCalls[0].type).toBe('question'); + expect(showDialogCalls[0].actions.map((a) => a.label)).toEqual(['Cancel', 'Merge Now']); + }); + + it('runs the real merge with a second, separate client on confirmation, and shows a result dialog', async () => { + const toolPath = 'C:\\wsm\\WitcherScriptMerger.Headless.exe'; + const toolEnvironment = { WSM_GameDirectory: 'C:\\Games\\Witcher3' }; + const { api, showDialogCalls } = fakeApi({ + toolPath, + toolEnvironment, + dialogResponses: [{ action: 'Merge Now' }], + }); + const preview = mergeResult({ merged: ['a.ws'], skipped: ['b.xml'] }); + const final = mergeResult({ merged: ['a.ws'], skipped: [], dryRun: false }); + const { connect, calls, closedCount } = fakeConnect([{ result: preview }, { result: final }]); + + await resolveScriptConflicts(api, { connect }); + + expect(connect).toHaveBeenCalledTimes(2); + expect(closedCount()).toBe(2); + expect(calls[0].exePath).toBe(toolPath); + expect(calls[1].exePath).toBe(toolPath); + // The spawn env is the tool's registered WSM_* overrides merged on top of the + // current process's own environment (wsmEnv.ts's mergeWithProcessEnv) - not the + // bare override map, which would drop PATH etc. for a raw child_process.spawn. + expect(calls[0].env?.WSM_GameDirectory).toBe('C:\\Games\\Witcher3'); + expect(calls[1].env?.WSM_GameDirectory).toBe('C:\\Games\\Witcher3'); + + expect(showDialogCalls).toHaveLength(2); + expect(showDialogCalls[0].type).toBe('question'); + // final.skipped is empty, so the result dialog should read as a success, not a + // "some files still need attention" info dialog. + expect(showDialogCalls[1].type).toBe('success'); + }); + + it('shows an "info" (not "success") result dialog when the real merge still leaves skipped files', async () => { + const { api, showDialogCalls } = fakeApi({ + toolPath: 'C:\\wsm\\WitcherScriptMerger.Headless.exe', + dialogResponses: [{ action: 'Merge Now' }], + }); + const preview = mergeResult({ merged: ['a.ws'], skipped: ['b.xml'] }); + const final = mergeResult({ merged: ['a.ws'], skipped: ['b.xml'] }); + const { connect } = fakeConnect([{ result: preview }, { result: final }]); + + await resolveScriptConflicts(api, { connect }); + + expect(showDialogCalls[1].type).toBe('info'); + }); + + it('reports failure via showErrorNotification, without any dialog, when the preview itself fails', async () => { + const { api, showDialogCalls, errorNotifications } = fakeApi({ + toolPath: 'C:\\wsm\\WitcherScriptMerger.Headless.exe', + }); + const { connect, closedCount } = fakeConnect([{ error: new Error('spawn failed') }]); + + await resolveScriptConflicts(api, { connect }); + + expect(showDialogCalls).toHaveLength(0); + expect(errorNotifications).toHaveLength(1); + // The client this connect call produced must still be closed even though + // mergeConflicts() itself threw - proves the `finally` in + // runMergeConflictsWorkflow runs on the error path too. + expect(closedCount()).toBe(1); + }); + + it('reports failure via showErrorNotification when the real merge fails after the user already confirmed', async () => { + const { api, showDialogCalls, errorNotifications } = fakeApi({ + toolPath: 'C:\\wsm\\WitcherScriptMerger.Headless.exe', + dialogResponses: [{ action: 'Merge Now' }], + }); + const preview = mergeResult({ merged: ['a.ws'], skipped: ['b.xml'] }); + const { connect, closedCount } = fakeConnect([{ result: preview }, { error: new Error('merge failed') }]); + + await resolveScriptConflicts(api, { connect }); + + // The preview dialog was shown (that part succeeded); only the post-confirm merge + // failed, so exactly one dialog and one error notification. + expect(showDialogCalls).toHaveLength(1); + expect(errorNotifications).toHaveLength(1); + expect(closedCount()).toBe(2); + }); + + it('reports failure via showErrorNotification instead of throwing when something unexpected fails outside the WSM-client calls themselves (e.g. api.showDialog)', async () => { + // Regression coverage for a real bug caught in code review: building/showing the + // preview dialog used to sit outside any try/catch, so an unexpected failure there + // (originally: buildMergeSummaryDialogContent throwing on a malformed response + // missing functionLevelDecisions - now fixed at its root in mergePanel.ts's own + // defensive default) would reject resolveScriptConflicts's own promise, caught only + // by the last-resort, log-only `.catch()` in registerResolveScriptConflictsAction - + // the user would see nothing at all. This proves the outer safety net around the + // whole function body catches that shape of failure too, independent of the + // specific field that used to trigger it. + const { api, errorNotifications } = fakeApi({ + toolPath: 'C:\\wsm\\WitcherScriptMerger.Headless.exe', + }); + (api.showDialog as unknown as ReturnType).mockImplementationOnce(() => { + throw new Error('unexpected dialog failure'); + }); + const preview = mergeResult({ merged: ['a.ws'], skipped: ['b.xml'] }); + const { connect } = fakeConnect([{ result: preview }]); + + await expect(resolveScriptConflicts(api, { connect })).resolves.toBeUndefined(); + expect(errorNotifications).toHaveLength(1); + }); +}); diff --git a/vortex-extension/src/resolveAction.ts b/vortex-extension/src/resolveAction.ts new file mode 100644 index 0000000..fdb2452 --- /dev/null +++ b/vortex-extension/src/resolveAction.ts @@ -0,0 +1,244 @@ +import { log, selectors, types } from 'vortex-api'; +import { WSM_TOOL_ID } from './discoveredTool'; +import { isWitcher3Active, WITCHER3_GAME_ID } from './gating'; +import { MergeConflictsArgs, MergeConflictsResult, WsmMcpClient, WsmMcpClientOptions } from './mcpClient'; +import { buildMergeSummaryDialogContent } from './mergePanel'; +import { mergeWithProcessEnv } from './wsmEnv'; + +/** + * The "Resolve Script Conflicts" action: a `context.registerAction` entry (Mods page + * toolbar - group `'mod-icons'`, confirmed against real, current Vortex source rather + * than guessed: `gh search code "registerAction(\"mod-icons\"" --repo Nexus-Mods/Vortex` + * turns up exactly this group used for the Mods page's own global-not-per-row toolbar + * buttons, e.g. `open-directory`'s "Open Mod Staging Folder"/"Open Game Folder" and + * `mod_management`'s own Deploy/Purge buttons - the same place `docs/vortex-extension- + * design.md` ยง5 describes this action belonging) that spawns a `WsmMcpClient` per + * `mcpClient.ts`'s own documented lifecycle policy (spawn per user-initiated workflow, + * close in a `finally`), previews a merge via `mergeConflicts({dryRun: true})`, shows + * the preview to the user (via `mergePanel.ts`'s dialog-content builder), and on + * confirmation runs the real merge via a *second*, freshly-spawned client (see + * `resolveScriptConflicts`'s own comment for why not the same client instance). + * + * v1 scope, deliberately: a single "merge everything" flow, not per-file selection or + * `orderOverrides` - `mergeConflicts` is called with no `relativePaths` filter (every + * detected conflict) and no `orderOverrides` (default load-order-comparer ordering). + * Exposing that level of control was explicitly optional for v1 per this unit's own + * task description; noted here (and in this unit's PR description) as a deliberate + * choice, not an oversight. + * + * No separate "launch the GUI to resolve a skipped file by hand" fallback is built here + * - a real (non-dry-run) `merge_conflicts` call already writes a git/diff3-style + * conflict-marker sidecar under `DiffPlexConflicts/` for every file that couldn't + * auto-solve, even with the function-level fallback, and opens it in the OS's default + * associated editor as a side effect (`DiffPlexMergeEngine.MergeHeadless` -> + * `Tools/FileOpener.Open`, unless the call was a dry run) - see this unit's own task + * description and `WitcherScriptMerger.Core/Mcp/CLAUDE.md`'s "Minimal required + * permissions" section. `mergePanel.ts`'s dialog content for a non-preview result + * therefore just tells the user that already happened, rather than this file building a + * second, redundant "launch WSM's GUI" mechanism - which would also need its own + * separate binary-acquisition step, since Unit F only downloads the GUI-less Headless + * build. + */ + +const ACTIVITY_NOTIFICATION_ID = 'witcherscriptmerger-vortex-resolve-conflicts-activity'; + +/** The subset of `WsmMcpClient` this file actually needs - lets unit tests inject a + * fake without spawning a real WSM process (mirrors `toolAcquisition.ts`'s own + * `client`/`extractor` test seams). */ +export interface WsmMergeClient { + mergeConflicts(args?: MergeConflictsArgs): Promise; + close(): Promise; +} + +export interface ResolveScriptConflictsDeps { + /** Test-only seam - defaults to `WsmMcpClient.connect`. */ + connect?: (options: WsmMcpClientOptions) => Promise; +} + +/** Resolves the WSM tool `toolAcquisition.ts` already registered for Witcher 3 (via + * `actions.addDiscoveredTool`), if any - see `discoveredTool.ts`'s own doc comment for + * the `IDiscoveredTool` shape this reads (`path`, `environment`). Returns `undefined` + * when no tool has been acquired/registered yet, exactly like `selectors.discoveryByGame` + * itself can return `undefined` for a game with no discovery result at all + * (`toolAcquisition.ts` already relies on this same optional-chaining pattern). */ +function getDiscoveredWsmTool(api: types.IExtensionApi): types.IDiscoveredTool | undefined { + return selectors.discoveryByGame(api.getState(), WITCHER3_GAME_ID)?.tools?.[WSM_TOOL_ID]; +} + +/** + * Drives the full preview -> confirm -> merge workflow. Exported (not just wired + * privately into the registered action below) so this orchestration is directly unit + * testable against a fake `connect`/fake `api`, without a real WSM process or a real + * Vortex dialog system. + * + * **Two separate client instances, not one kept alive across the confirmation wait**: + * the dry-run preview's client is connected, used, and closed before the preview + * dialog is ever shown; if the user confirms, a *second*, freshly-spawned client + * handles the real merge. `mcpClient.ts`'s own lifecycle policy is "spawn per + * user-initiated workflow, tear down when the caller is done with it" - keeping a + * child process alive and idle for as long as the user takes to read and act on the + * preview dialog (seconds to indefinitely long) isn't "done with it" in any useful + * sense, and every WSM MCP tool call already re-scans the mods folder from scratch + * server-side regardless (`WitcherScriptMerger.Core/Mcp/CLAUDE.md`), so there's no + * real work saved by reuse here - only an idle process kept around for no benefit. + */ +export async function resolveScriptConflicts( + api: types.IExtensionApi, + deps: ResolveScriptConflictsDeps = {}, +): Promise { + const connect = deps.connect ?? ((options: WsmMcpClientOptions) => WsmMcpClient.connect(options)); + + const tool = getDiscoveredWsmTool(api); + if (!tool?.path) { + api.sendNotification?.({ + id: 'witcherscriptmerger-vortex-resolve-conflicts-no-tool', + type: 'error', + title: 'WitcherScriptMerger not found', + message: 'Acquire WitcherScriptMerger before resolving script conflicts.', + }); + return; + } + + const env = mergeWithProcessEnv(tool.environment ?? {}); + + // The two try/catches below (around each runMergeConflictsWorkflow call) give a + // phase-specific error message ("preview" vs. "merge") for the failure mode that's + // actually expected to happen there (the WSM process failing to spawn/respond). This + // outer try/catch is a separate, general safety net around everything else in this + // function's body - dialog-content building (mergePanel.ts) and the showDialog calls + // themselves - so that ANY unexpected exception still reaches the user via + // reportFailure instead of becoming a silently-swallowed rejected promise (caught + // only by the last-resort, log-only `.catch()` around this function's own caller in + // registerResolveScriptConflictsAction). Caught in this unit's own code review: a + // missing `functionLevelDecisions` field on an older WSM binary's response used to + // be exactly this kind of unguarded failure (now also fixed at its root in + // mergePanel.ts's own defensive default) - this net exists for whatever the next + // one of these turns out to be, not just that specific case. + try { + let preview: MergeConflictsResult; + try { + preview = await runMergeConflictsWorkflow(api, connect, tool.path, env, { + dryRun: true, + activityMessage: 'Scanning for mergeable script conflicts...', + }); + } catch (err) { + reportFailure(api, 'Failed to preview script-conflict merges', err); + return; + } + + if (preview.merged.length === 0 && preview.skipped.length === 0 && preview.unmatched.length === 0) { + await api.showDialog?.( + 'info', + 'Script Merger', + { text: 'No script conflicts were detected - nothing to merge.' }, + [{ label: 'Close', default: true }], + ); + return; + } + + const previewChoice = await api.showDialog?.( + 'question', + 'Resolve Script Conflicts - Preview', + buildMergeSummaryDialogContent(preview, { isPreview: true }), + [{ label: 'Cancel' }, { label: 'Merge Now', default: true }], + ); + + if (previewChoice?.action !== 'Merge Now') { + return; + } + + let result: MergeConflictsResult; + try { + result = await runMergeConflictsWorkflow(api, connect, tool.path, env, { + dryRun: false, + activityMessage: 'Merging script conflicts...', + }); + } catch (err) { + reportFailure(api, 'Failed to merge script conflicts', err); + return; + } + + await api.showDialog?.( + result.skipped.length > 0 ? 'info' : 'success', + 'Resolve Script Conflicts - Result', + buildMergeSummaryDialogContent(result, { isPreview: false }), + [{ label: 'Close', default: true }], + ); + } catch (err) { + reportFailure(api, 'Resolve Script Conflicts failed unexpectedly', err); + } +} + +/** One spawn-call-close cycle, wrapped with an 'activity' notification (spinner icon, + * no dismiss button - see `INotification`'s own `type`/`noDismiss` docs) so the user + * gets feedback while a scan/merge (which can take a while for a large mods folder) is + * in flight. The notification is dismissed in a `finally` so it never lingers past this + * call whether `mergeConflicts` succeeds, throws, or `client.close()` itself throws. */ +async function runMergeConflictsWorkflow( + api: types.IExtensionApi, + connect: (options: WsmMcpClientOptions) => Promise, + exePath: string, + env: NodeJS.ProcessEnv, + options: MergeConflictsArgs & { activityMessage: string }, +): Promise { + const { activityMessage, ...args } = options; + + api.sendNotification?.({ + id: ACTIVITY_NOTIFICATION_ID, + type: 'activity', + title: 'Script Merger', + message: activityMessage, + noDismiss: true, + }); + + try { + const client = await connect({ exePath, env }); + try { + return await client.mergeConflicts(args); + } finally { + await client.close(); + } + } finally { + api.dismissNotification?.(ACTIVITY_NOTIFICATION_ID); + } +} + +function reportFailure(api: types.IExtensionApi, message: string, err: unknown): void { + const detail = err instanceof Error ? err : String(err); + log('warn', 'witcherscriptmerger-vortex: resolveScriptConflicts failed', { + message, + error: err instanceof Error ? err.message : String(err), + }); + api.showErrorNotification?.(message, detail); +} + +/** + * Registers the "Resolve Script Conflicts" action. Called directly from `index.ts`'s + * `main()` - NOT deferred through `context.once` (see `index.ts`'s own header comment + * for why: `IExtensionContext.once`'s own doc comment says registrations are expected + * to already be done by the time it fires). Gated on Witcher 3 being the active game + * via the same live `condition` callback pattern `gating.ts`'s own doc comment + * prescribes for every registration this extension adds - re-evaluated by Vortex on + * every game-mode switch, not just checked once at load time. + */ +export function registerResolveScriptConflictsAction(context: types.IExtensionContext): void { + context.registerAction( + 'mod-icons', + 300, + 'conflict', + {}, + 'Resolve Script Conflicts', + () => { + resolveScriptConflicts(context.api).catch((err: unknown) => { + // resolveScriptConflicts already reports failures it knows about via + // showErrorNotification - this is a last-resort catch for anything that + // escaped that (e.g. a bug in the dialog-content builder itself), so it must + // never throw out of a registerAction callback. + log('warn', 'witcherscriptmerger-vortex: resolveScriptConflicts action callback failed unexpectedly', { + error: err instanceof Error ? err.message : String(err), + }); + }); + }, + () => isWitcher3Active(context.api), + ); +} diff --git a/vortex-extension/test/mcpClient.integration.test.ts b/vortex-extension/test/mcpClient.integration.test.ts index 40318d1..011a5f1 100644 --- a/vortex-extension/test/mcpClient.integration.test.ts +++ b/vortex-extension/test/mcpClient.integration.test.ts @@ -146,3 +146,204 @@ describe('WsmMcpClient integration (real WSM Headless process)', () => { } }, 30_000); }); + +// Real merge round trip: this unit (Unit H, the "Resolve Script Conflicts" action + +// merge panel) drives `mergeConflicts({dryRun: true})` then, on confirm, +// `mergeConflicts({dryRun: false})` - src/mergePanel.ts and src/resolveAction.ts's own +// unit tests exercise the panel/orchestration logic against a fake client, but this is +// the proof that a *real* dry-run/real-run round trip against a real WSM process +// returns sensible merged/skipped/functionLevelDecisions data for that logic to +// consume. Two conflicts are staged: a .ws script two mods edit on disjoint lines +// (auto-solves cleanly, whole-file 3-way merge, no conflict blocks at all) and an .xml +// file two mods edit on the very same line to different values (a genuine, +// non-whitespace conflict). The second is deliberately .xml, not .ws: +// DiffPlexMergeEngine.TryFunctionLevelRescue only ever attempts the function-level +// fallback for a ".ws" outputPath, so an .xml conflict can never be silently rescued +// out of `skipped` by that fallback - keeping this fixture's "stays genuinely skipped" +// outcome deterministic without having to out-think that engine's own tiebreak logic. +// +// Reuses the already-built HEADLESS_EXE from the top-level beforeAll above (this file +// is loaded once by vitest; that beforeAll always runs before any test in this file, +// including this describe block's own) rather than triggering a second `dotnet build` +// - avoids racing that build if vitest ever schedules this file's describes +// concurrently. Only the build *output* is reused (via a plain file copy into a second, +// independently-configured scratch install); nothing here shares mutable state with the +// describe block above. +// +// Known, expected side effect of the second `it` below: a real (non-dry) merge_conflicts +// call against a genuine conflict makes the real WSM process call +// Tools/FileOpener.Open on the conflict-marker sidecar it writes (DiffPlexMergeEngine. +// MergeHeadless, openConflictMarkers defaults true for dryRun: false) - i.e. it may +// briefly launch a program or the OS's "how do you want to open this file?" picker for +// the written `.conflict` sidecar. This is real, intentional WSM behavior (see +// WitcherScriptMerger.Core/Mcp/CLAUDE.md's "Minimal required permissions" section), not +// a test bug - Process.Start returns immediately either way, so it doesn't block or fail +// this test even if left uninteracted with. +describe('WsmMcpClient integration - real merge round trip (auto-solve + genuine conflict)', () => { + const VANILLA_WS_CONTENT = + 'function FuncA() {\r\n' + + ' var a : int;\r\n' + + ' a = 1;\r\n' + + '}\r\n' + + '\r\n' + + 'function FuncB() {\r\n' + + ' var b : int;\r\n' + + ' b = 1;\r\n' + + '}\r\n'; + const MOD1_WS_CONTENT = VANILLA_WS_CONTENT.replace('a = 1;', 'a = 100;'); + const MOD2_WS_CONTENT = VANILLA_WS_CONTENT.replace('b = 1;', 'b = 200;'); + + const VANILLA_XML_CONTENT = '\r\n \r\n\r\n'; + const MOD1_XML_CONTENT = VANILLA_XML_CONTENT.replace('value="100"', 'value="500"'); + const MOD2_XML_CONTENT = VANILLA_XML_CONTENT.replace('value="100"', 'value="999"'); + + const MERGED_SCRIPT_RELATIVE_PATH = path.join('game', 'itemA.ws'); + const CONFLICTING_XML_RELATIVE_PATH = path.join('gameplay', 'items.xml'); + + function buildMergeScratchConfig(gameDirectory: string, modsDirectory: string): string { + // Same shape as buildScratchConfig above, but with a real GameDirectory (needed so + // ScriptsDirectory/GetVanillaFile resolve to real vanilla content this fixture + // writes - see this describe block's own top comment) rather than the empty one + // that outer function hardcodes. + return ` + + + + + + + + + + + + + + + + + + +`; + } + + let mergeScratchDir: string; + let mergeExePath: string; + + beforeAll(() => { + mergeScratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wsm-mcp-merge-test-')); + fs.cpSync(HEADLESS_BUILD_DIR, mergeScratchDir, { recursive: true }); + + const gameDir = path.join(mergeScratchDir, 'Game'); + const modsDir = path.join(mergeScratchDir, 'Mods'); + const vanillaScriptsDir = path.join(gameDir, 'content', 'content0', 'scripts', 'game'); + const vanillaXmlDir = path.join(gameDir, 'gameplay'); + const mod1ScriptDir = path.join(modsDir, 'mod0001_First', 'content', 'scripts', 'game'); + const mod2ScriptDir = path.join(modsDir, 'mod0002_Second', 'content', 'scripts', 'game'); + const mod1XmlDir = path.join(modsDir, 'mod0001_First', 'gameplay'); + const mod2XmlDir = path.join(modsDir, 'mod0002_Second', 'gameplay'); + + for (const dir of [vanillaScriptsDir, vanillaXmlDir, mod1ScriptDir, mod2ScriptDir, mod1XmlDir, mod2XmlDir]) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(path.join(vanillaScriptsDir, 'itemA.ws'), VANILLA_WS_CONTENT, 'utf8'); + fs.writeFileSync(path.join(mod1ScriptDir, 'itemA.ws'), MOD1_WS_CONTENT, 'utf8'); + fs.writeFileSync(path.join(mod2ScriptDir, 'itemA.ws'), MOD2_WS_CONTENT, 'utf8'); + + fs.writeFileSync(path.join(vanillaXmlDir, 'items.xml'), VANILLA_XML_CONTENT, 'utf8'); + fs.writeFileSync(path.join(mod1XmlDir, 'items.xml'), MOD1_XML_CONTENT, 'utf8'); + fs.writeFileSync(path.join(mod2XmlDir, 'items.xml'), MOD2_XML_CONTENT, 'utf8'); + + fs.writeFileSync( + path.join(mergeScratchDir, 'WitcherScriptMerger.Headless.dll.config'), + buildMergeScratchConfig(gameDir, modsDir), + 'utf8', + ); + + mergeExePath = path.join(mergeScratchDir, 'WitcherScriptMerger.Headless.exe'); + }, 60_000); + + afterAll(() => { + if (!mergeScratchDir) { + return; + } + try { + fs.rmSync(mergeScratchDir, { recursive: true, force: true }); + } catch { + // Best-effort, not a test failure: this describe block's own real-run test + // deliberately exercises FileOpener.Open on the genuine conflict's sidecar (see + // that test's own comment) - whatever program the OS launched for the + // `.conflict` file may still be holding a lock on it (or its containing + // directory) by the time this runs, making the whole scratch tree + // undeletable-for-now on Windows (EPERM). That's a real, expected consequence of + // testing this genuine behavior end-to-end, not a bug in this test - the leftover + // temp directory needs the same manual housekeeping + // Paths.DiffPlexConflictsDirectory's own doc comment already describes for the + // real DiffPlexConflicts folder (nothing sweeps it automatically either). + } + }); + + it('a dry run previews the auto-solving file as merged and the genuinely conflicting file as skipped, without writing anything', async () => { + const client = await WsmMcpClient.connect({ exePath: mergeExePath }); + try { + const preview = await client.mergeConflicts({ dryRun: true }); + + expect(preview.dryRun).toBe(true); + expect(preview.merged).toEqual([MERGED_SCRIPT_RELATIVE_PATH]); + expect(preview.skipped).toEqual([CONFLICTING_XML_RELATIVE_PATH]); + expect(preview.unmatched).toEqual([]); + expect(Array.isArray(preview.functionLevelDecisions)).toBe(true); + + const mergedScriptPath = path.join( + mergeScratchDir, + 'Mods', + 'mod0000_MergedFiles', + 'content', + 'scripts', + 'game', + 'itemA.ws', + ); + expect(fs.existsSync(mergedScriptPath)).toBe(false); + } finally { + await client.close(); + } + }, 30_000); + + it('a real run merges the auto-solving file with both mods\' changes and leaves a conflict-marker sidecar for the genuinely conflicting one', async () => { + const client = await WsmMcpClient.connect({ exePath: mergeExePath }); + try { + const result = await client.mergeConflicts({ dryRun: false }); + + expect(result.dryRun).toBe(false); + expect(result.merged).toEqual([MERGED_SCRIPT_RELATIVE_PATH]); + expect(result.skipped).toEqual([CONFLICTING_XML_RELATIVE_PATH]); + expect(result.unmatched).toEqual([]); + + const mergedScriptPath = path.join( + mergeScratchDir, + 'Mods', + 'mod0000_MergedFiles', + 'content', + 'scripts', + 'game', + 'itemA.ws', + ); + expect(fs.existsSync(mergedScriptPath)).toBe(true); + const mergedText = fs.readFileSync(mergedScriptPath, 'utf16le'); + expect(mergedText).toContain('a = 100;'); + expect(mergedText).toContain('b = 200;'); + + // See DiffPlexMergeEngine.GetConflictMarkerPath's own comment for why the + // sidecar lands in a dedicated DiffPlexConflicts folder next to the exe, keyed + // by an XxHash32 of the file's own would-be output path, rather than at that + // output path itself. + const conflictsDir = path.join(mergeScratchDir, 'DiffPlexConflicts'); + expect(fs.existsSync(conflictsDir)).toBe(true); + const sidecars = fs.readdirSync(conflictsDir); + expect(sidecars.some((name) => name.startsWith('items.xml.'))).toBe(true); + } finally { + await client.close(); + } + }, 30_000); +});