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
49 changes: 41 additions & 8 deletions vortex-extension/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,17 @@ 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.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.
* logic (context.once, context.api.getState/events.on/onAsync, context.registerAction/
* registerDashlet - the register calls added alongside resolveAction.ts's and
* mergeHistoryDashlet.ts's own registrations, both 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.
*
* `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.
* `registerDashlet` needs a real (no-op) implementation, not just a type - without this,
* every test below would throw "context.registerDashlet is not a function" the moment
* main(context) runs; mergeHistoryDashlet.test.ts covers registerMergeHistoryDashlet's
* own argument-shape/gating behavior directly.
*
* `profiles` backs `selectors.profileById` (via the shared `vortexApiStub.ts`) -
* `checkForConflictsAfterDeploy` (index.ts) resolves `did-deploy`'s own `profileId`
Expand All @@ -59,11 +61,13 @@ function fakeContext(initialActiveGameId: string | undefined, profiles: Record<s
let onceCallback: (() => void) | undefined;
const eventListeners = new Map<string, Array<() => void>>();
const asyncListeners = new Map<string, (...args: unknown[]) => Promise<unknown>>();
const registerActionMock = vi.fn();

const context = {
once: (callback: () => void) => {
onceCallback = callback;
},
registerAction: registerActionMock,
registerDashlet: (..._args: unknown[]) => {
// Intentionally a no-op in tests - mergeHistoryDashlet.test.ts covers
// registerMergeHistoryDashlet's own argument-shape/gating behavior directly.
Expand Down Expand Up @@ -91,6 +95,7 @@ function fakeContext(initialActiveGameId: string | undefined, profiles: Record<s
setActiveGame: (gameId: string | undefined) => {
state.activeGameId = gameId;
},
registerActionMock,
};
}

Expand Down Expand Up @@ -161,6 +166,34 @@ describe('main (index.ts)', () => {
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);
});

describe('did-deploy conflict scanning', () => {
it('registers a did-deploy handler via onAsync (not events.on) at context.once time', () => {
ensureWsmToolRegisteredMock.mockClear().mockResolvedValue(false);
Expand Down
16 changes: 14 additions & 2 deletions vortex-extension/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { isWsmToolAcquired, scanWsmConflicts } from './conflictScan';
import { isModOrDependencyInstallActive, notifyConflictsIfChanged } from './conflictNotifications';
import { isWitcher3Active, WITCHER3_GAME_ID } from './gating';
import { registerMergeHistoryDashlet } from './mergeHistoryDashlet';
import { registerResolveScriptConflictsAction } from './resolveAction';
import { ensureWsmToolRegistered } from './toolAcquisition';

/**
Expand Down Expand Up @@ -49,8 +50,13 @@ import { ensureWsmToolRegistered } from './toolAcquisition';
* `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.
* condition imperatively on every `'gamemode-activated'` event.
*
* This unit (the resolve action) adds the fourth real registration:
* `registerResolveScriptConflictsAction` (`./resolveAction`), called directly in `main`
* alongside `registerMergeHistoryDashlet` above, following the exact same
* directly-in-`main`, gate-on-`isWitcher3Active`-via-`condition` pattern - see
* `resolveAction.ts`'s own doc comment for how this unit's own action does exactly that.
*
* This extension must never call `context.registerGame('witcher3', ...)` - Vortex's own
* built-in `game-witcher3` extension already owns that registration; this extension is a
Expand Down Expand Up @@ -170,6 +176,12 @@ function main(context: types.IExtensionContext): boolean {
context.api.onAsync('did-deploy', checkForConflictsAfterDeploy);
});

// 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;
}

Expand Down
12 changes: 12 additions & 0 deletions vortex-extension/src/mcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>` 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 {
Expand Down
132 changes: 132 additions & 0 deletions vortex-extension/src/mergePanel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest';
import { MergeConflictsResult } from './mcpClient';
import { buildMergeSummaryDialogContent } from './mergePanel';

function result(overrides: Partial<MergeConflictsResult> = {}): 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');
});
});
Loading
Loading