From 1652dd631fd28f91d3aa772c8a17ddb1c6aed549 Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Tue, 16 Jun 2026 10:33:42 -0700 Subject: [PATCH 01/12] Convert X Rebirth extension to the Game Description Language Replace the hand-written X Rebirth game extension with a declarative game.yaml compiled by the GDL toolchain (added as the game-description-language submodule). The extension is now a game.yaml, game art, and a small src/hooks.ts holding the two imperative pieces GDL can't express declaratively: the content.xml installer (parses XML and emits attribute instructions) and the three in-game health checks. This is a 1:1 behavioral replacement. The game-extension-test harness produces identical results against every X Rebirth mod on Nexus (956 passed, 15 skipped) for both the original and converted extension. Also teaches @vortex/game-extension-test to load GDL extensions (detects game.yaml, loads the generated entry through the harness module aliases, and captures health checks registered via registerHealthCheck), so future GDL game conversions are covered by the same regression harness. The GDL submodule tracks feat/vortex-xrebirth-integration, which adds the features this conversion needs (declarative installer predicates, a custom install hook, runtime diagnostics, stop patterns, and Steam app id derivation). --- .gitignore | 1 + .gitmodules | 4 + extensions/games/game-xrebirth/build.mjs | 42 ++- extensions/games/game-xrebirth/game.yaml | 142 +++++++++ .../game-xrebirth/{assets => }/gameart.webp | Bin extensions/games/game-xrebirth/package.json | 3 +- .../game-xrebirth/src/diagnostic.test.ts | 128 -------- .../games/game-xrebirth/src/diagnostic.ts | 166 ----------- .../games/game-xrebirth/src/hooks.test.ts | 172 +++++++++++ extensions/games/game-xrebirth/src/hooks.ts | 273 ++++++++++++++++++ extensions/games/game-xrebirth/src/index.js | 117 -------- extensions/games/game-xrebirth/src/index.ts | 45 --- .../game-xrebirth/src/installers.test.ts | 248 ---------------- .../games/game-xrebirth/src/installers.ts | 219 -------------- .../games/game-xrebirth/src/stopPatterns.ts | 48 --- game-description-language | 1 + .../game-extension-test/src/loadExtension.ts | 81 ++++-- packages/game-extension-test/vitest.config.ts | 13 +- .../vitest.fixtures.config.ts | 13 +- 19 files changed, 716 insertions(+), 1000 deletions(-) create mode 100644 .gitmodules create mode 100644 extensions/games/game-xrebirth/game.yaml rename extensions/games/game-xrebirth/{assets => }/gameart.webp (100%) delete mode 100644 extensions/games/game-xrebirth/src/diagnostic.test.ts delete mode 100644 extensions/games/game-xrebirth/src/diagnostic.ts create mode 100644 extensions/games/game-xrebirth/src/hooks.test.ts create mode 100644 extensions/games/game-xrebirth/src/hooks.ts delete mode 100644 extensions/games/game-xrebirth/src/index.js delete mode 100644 extensions/games/game-xrebirth/src/index.ts delete mode 100644 extensions/games/game-xrebirth/src/installers.test.ts delete mode 100644 extensions/games/game-xrebirth/src/installers.ts delete mode 100644 extensions/games/game-xrebirth/src/stopPatterns.ts create mode 160000 game-description-language diff --git a/.gitignore b/.gitignore index 023a303d11..03bdb63a28 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ dist dist_custom dist_portable dist_web +.gdl-out out doc coverage diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..1c2906ba26 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "game-description-language"] + path = game-description-language + url = https://github.com/Nexus-Mods/game-description-language.git + branch = feat/vortex-xrebirth-integration diff --git a/extensions/games/game-xrebirth/build.mjs b/extensions/games/game-xrebirth/build.mjs index 9af8003161..bda2e52fc1 100644 --- a/extensions/games/game-xrebirth/build.mjs +++ b/extensions/games/game-xrebirth/build.mjs @@ -1,10 +1,42 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, rmSync, cpSync } from "node:fs"; import * as path from "node:path"; +import { pathToFileURL } from "node:url"; -import { createConfig, bundle } from "../../../scripts/extensions-rolldown.mjs"; +// This extension is described declaratively in game.yaml and compiled by the +// Game Description Language (GDL) toolchain (the game-description-language +// submodule) into a Vortex-loadable dist/index.js. The imperative bits +// (content.xml installer, health checks) live in src/hooks.ts. const extensionPath = path.resolve(import.meta.dirname); -const entryPoint = path.resolve(extensionPath, "src", "index.ts"); -const output = path.resolve(extensionPath, "dist", "index.cjs"); +const gdlPath = path.resolve(extensionPath, "..", "..", "..", "game-description-language"); +const gdlDist = path.join(gdlPath, "dist"); -const config = createConfig(entryPoint, output); -await bundle(config); +// Ensure the GDL toolchain is built (it ships as a submodule; its node_modules +// must already be installed). Build it on demand if dist is missing. +if (!existsSync(path.join(gdlDist, "cli.js"))) { + execFileSync( + "node", + [path.join(gdlPath, "node_modules", "typescript", "bin", "tsc"), "-p", "tsconfig.json"], + { + cwd: gdlPath, + stdio: "inherit", + }, + ); + cpSync(path.join(gdlPath, "src", "templates"), path.join(gdlDist, "templates"), { + recursive: true, + }); + cpSync( + path.join(gdlPath, "src", "bundler", "tsconfig.bundle.json"), + path.join(gdlDist, "bundler", "tsconfig.bundle.json"), + ); +} + +// Clean previous output so stale artifacts never shadow a fresh build. +rmSync(path.join(extensionPath, "dist"), { recursive: true, force: true }); + +// Dynamic import needs a file:// URL for absolute paths on Windows. +const { buildExtension } = await import( + pathToFileURL(path.join(gdlDist, "commands", "build.js")).href +); +await buildExtension({ cwd: extensionPath }); diff --git a/extensions/games/game-xrebirth/game.yaml b/extensions/games/game-xrebirth/game.yaml new file mode 100644 index 0000000000..0f4a1cb044 --- /dev/null +++ b/extensions/games/game-xrebirth/game.yaml @@ -0,0 +1,142 @@ +gdl: 1 +version: 1.0.1 + +game: + id: xrebirth + name: X Rebirth + executable: XRebirth.exe + requiredFiles: [XRebirth.exe] + logo: gameart.webp + author: Black Tree Gaming Ltd. + nexusDomain: xrebirth + # All X Rebirth mods deploy under the game's `extensions` folder. + queryModPath: extensions + +stores: + steam: "2870" + +# Mod types are classification tags only: every type deploys to the same +# `extensions` folder (matching the original extension, which set modType tags +# but never gave them distinct paths). +modTypes: + - { id: xrebirth-savegame, name: "X Rebirth - savegame", path: "${installPath}/extensions" } + - { + id: xrebirth-shader-injector, + name: "X Rebirth - shader injector", + path: "${installPath}/extensions", + } + - { id: xrebirth-utility, name: "X Rebirth - utility", path: "${installPath}/extensions" } + - { id: xrebirth-dropin, name: "X Rebirth - drop-in", path: "${installPath}/extensions" } + - { id: xrebirth-save-patch, name: "X Rebirth - save patch", path: "${installPath}/extensions" } + - { + id: xrebirth-documentation, + name: "X Rebirth - documentation", + path: "${installPath}/extensions", + } + +# Priority order (lower = tried first) mirrors the original PRIORITIES table. +installers: + # 50 - canonical content.xml mod: parses the XML and emits attribute + # instructions, so it runs through a custom install hook. + - id: content-xml + priority: 50 + when: { hasFile: "**/content.xml" } + install: { hook: installContentXml } + + # 60 - save_NNN.xml / quicksave.xml at any depth (keep archive layout). + - id: savegame + priority: 60 + when: { matches: '(?i)(^|/)(quicksave|save_\d+)\.xml$' } + copy: { stripCommonRoot: false } + modType: xrebirth-savegame + + # 65 - SweetFX / ReShade shader injector markers. + - id: shader-injector + priority: 65 + when: + any: + - { matches: '(?i)(^|/)d3d9\.dll$' } + - { matches: '(?i)(^|/)dxgi\.dll$' } + - { matches: '(?i)(^|/)d3d9\.ini$' } + - { matches: '(?i)(^|/)SweetFX([\\/]|_)' } + - { matches: "(?i)(^|/)reshade-shaders/" } + - { matches: "(?i)(^|/)ReShade/" } + copy: { stripCommonRoot: true } + modType: xrebirth-shader-injector + + # 70 - any .exe present (standalone utility/tool). + - id: utility + priority: 70 + when: { extensions: { list: [".exe"], mode: any } } + copy: { stripCommonRoot: true } + modType: xrebirth-utility + + # 75 - looks like an X Rebirth drop-in (matches a stopPattern). + - id: dropin + priority: 75 + when: { stopPatterns: true } + copy: { stripCommonRoot: true } + modType: xrebirth-dropin + + # 80 - every file is .xml/.txt and at least one is .xml (XML-only save patch). + - id: save-patch + priority: 80 + when: + all: + - { extensions: { list: [".xml", ".txt"], mode: all } } + - { extensions: { list: [".xml"], mode: any } } + copy: { stripCommonRoot: false } + modType: xrebirth-save-patch + + # 90 - pure documentation (every file is a doc extension). + - id: documentation + priority: 90 + when: + extensions: + list: + [ + ".pdf", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".svg", + ".xlsx", + ".xls", + ".docx", + ".doc", + ".odt", + ".ods", + ".md", + ".rtf", + ] + mode: all + copy: { stripCommonRoot: true } + modType: xrebirth-documentation + +# Drop-in conflict-detection patterns. Authored as regex (compiled +# case-insensitively, matching the original details.stopPatterns). +stopPatterns: + - { regex: '[^/]*\.cat$' } + - { regex: '[^/]*\.dat$' } + - { regex: '(^|/)t/[^/]+\.xml$' } + - { regex: '(^|/)lang\.dat$' } + - { regex: "(^|/)assets/.+" } + - { regex: '(^|/)libraries/.+\.xml$' } + - { regex: '(^|/)maps/.+\.xml$' } + - { regex: '(^|/)md/.+\.xml$' } + - { regex: "(^|/)cinematics/.+" } + - { regex: '(^|/)aiscripts/.+\.xml$' } + - { regex: '(^|/)voice-[^/]+/.+\.(ogg|wav)$' } + - { regex: "(^|/)ui/.+" } + - { regex: "(^|/)sfx/.+" } + - { regex: '[^/]*\.cur$' } + - { regex: '[^/]*\.(ogg|mp3|wav)$' } + - { regex: '[^/]*\.(mkv|mp4|webm)$' } + - { regex: '[^/]*\.ini$' } + +# In-game health checks (registered via context.registerHealthCheck). +diagnostics: + - hook: modHasFilesCheck + - hook: contentXmlCustomFileNameCheck + - hook: modShapeRecognisedCheck diff --git a/extensions/games/game-xrebirth/assets/gameart.webp b/extensions/games/game-xrebirth/gameart.webp similarity index 100% rename from extensions/games/game-xrebirth/assets/gameart.webp rename to extensions/games/game-xrebirth/gameart.webp diff --git a/extensions/games/game-xrebirth/package.json b/extensions/games/game-xrebirth/package.json index 079c0c19e3..f01348541a 100644 --- a/extensions/games/game-xrebirth/package.json +++ b/extensions/games/game-xrebirth/package.json @@ -3,8 +3,7 @@ "version": "1.0.1", "description": "Support for X Rebirth", "scripts": { - "_assets": "copyfiles -u 1 -f ./assets/* dist", - "build": "node build.mjs && pnpm run _assets && pnpm extractInfo", + "build": "node build.mjs", "test": "pnpm exec vitest run" }, "author": "Black Tree Gaming Ltd.", diff --git a/extensions/games/game-xrebirth/src/diagnostic.test.ts b/extensions/games/game-xrebirth/src/diagnostic.test.ts deleted file mode 100644 index 1bf6e98659..0000000000 --- a/extensions/games/game-xrebirth/src/diagnostic.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, test, expect } from "vitest"; - -import { healthChecks } from "./diagnostic"; -import { XREBIRTH_MOD_TYPES } from "./installers"; - -const [modHasFilesCheck, customFileNameCheck, modShapeCheck] = healthChecks; - -function mod(opts: { files?: string[]; attributes?: Record }): { - id: string; - state: "installed"; - type: string; - installationPath: string; - files: string[]; - attributes: Record; -} { - return { - id: "test", - state: "installed", - type: "", - installationPath: "test", - files: opts.files ?? [], - attributes: opts.attributes ?? {}, - }; -} - -const ctx = { - modId: "test", - files: [], - readFile: async () => Buffer.alloc(0), - attributes: {}, -}; - -describe("modHasFilesCheck", () => { - test("warns when installer produced no files", async () => { - const result = await modHasFilesCheck!.checkMod( - {}, - { ...ctx, files: mod({ files: [] }).files }, - ); - expect(result.status).toBe("warning"); - expect(result.message).toMatch(/no files/i); - }); - - test("passes when there is at least one file", async () => { - const result = await modHasFilesCheck!.checkMod({}, { ...ctx, files: ["a/b.xml"] }); - expect(result.status).toBe("passed"); - }); -}); - -describe("contentXmlCustomFileNameCheck", () => { - test("not applicable when not a content.xml mod", async () => { - const result = await customFileNameCheck!.checkMod( - {}, - { ...ctx, files: ["readme.txt"], attributes: {} }, - ); - expect(result.status).toBe("passed"); - expect(result.message).toMatch(/not applicable/i); - }); - - test("warns when content.xml mod is missing customFileName", async () => { - const result = await customFileNameCheck!.checkMod( - {}, - { ...ctx, files: ["mod/content.xml"], attributes: {} }, - ); - expect(result.status).toBe("warning"); - expect(result.message).toMatch(/missing customFileName/i); - }); - - test("passes when content.xml mod has customFileName", async () => { - const result = await customFileNameCheck!.checkMod( - {}, - { - ...ctx, - files: ["mod/content.xml"], - attributes: { customFileName: "Awesome Mod" }, - }, - ); - expect(result.status).toBe("passed"); - }); - - test("detects content.xml at any depth, case-insensitive", async () => { - const result = await customFileNameCheck!.checkMod( - {}, - { ...ctx, files: ["deep/path/Content.XML"], attributes: {} }, - ); - expect(result.status).toBe("warning"); - }); -}); - -describe("modShapeRecognisedCheck", () => { - test("recognised as content.xml mod", async () => { - const result = await modShapeCheck!.checkMod( - {}, - { ...ctx, files: ["a/content.xml"], attributes: {} }, - ); - expect(result.status).toBe("passed"); - expect(result.message).toMatch(/content\.xml/); - }); - - test("recognised by tagged modType", async () => { - const result = await modShapeCheck!.checkMod( - {}, - { - ...ctx, - files: ["tool.exe"], - attributes: { modType: XREBIRTH_MOD_TYPES.utility }, - }, - ); - expect(result.status).toBe("passed"); - expect(result.message).toContain(XREBIRTH_MOD_TYPES.utility); - }); - - test("recognised by stopPattern match", async () => { - const result = await modShapeCheck!.checkMod( - {}, - { ...ctx, files: ["data.cat"], attributes: {} }, - ); - expect(result.status).toBe("passed"); - expect(result.message).toMatch(/stopPatterns/); - }); - - test("warns when nothing recognised", async () => { - const result = await modShapeCheck!.checkMod( - {}, - { ...ctx, files: ["random.bin"], attributes: {} }, - ); - expect(result.status).toBe("warning"); - }); -}); diff --git a/extensions/games/game-xrebirth/src/diagnostic.ts b/extensions/games/game-xrebirth/src/diagnostic.ts deleted file mode 100644 index 8fc02e82e3..0000000000 --- a/extensions/games/game-xrebirth/src/diagnostic.ts +++ /dev/null @@ -1,166 +0,0 @@ -import * as path from "node:path"; - -import { types, util } from "@nexusmods/vortex-api"; - -import { XREBIRTH_MOD_TYPES } from "./installers"; -import { XREBIRTH_STOP_PATTERNS } from "./stopPatterns"; - -const TAGGED_NON_CONTENT_XML = new Set([ - XREBIRTH_MOD_TYPES.savegame, - XREBIRTH_MOD_TYPES.shaderInjector, - XREBIRTH_MOD_TYPES.utility, - XREBIRTH_MOD_TYPES.documentation, - XREBIRTH_MOD_TYPES.savePatch, -]); - -const CATEGORY = types.HealthCheckCategory.Mods; -const TRIGGERS: types.HealthCheckTrigger[] = [ - types.HealthCheckTrigger.ModsChanged, - types.HealthCheckTrigger.Manual, -]; -const SEVERITY_INFO = types.HealthCheckSeverity.Info; -const SEVERITY_WARNING = types.HealthCheckSeverity.Warning; - -// Hoisted: the stopPatterns list is constant, so compile once instead of every -// check invocation. -const STOP_PATTERN_REGEXES = util.compileStopPatterns(XREBIRTH_STOP_PATTERNS); - -function isContentXmlMod(mod: types.IMod): boolean { - return mod.files.some((f) => path.basename(f).toLowerCase() === "content.xml"); -} - -function passed(checkId: string, message: string, startedAt: number): types.IHealthCheckResult { - return { - checkId, - status: "passed", - severity: SEVERITY_INFO, - message, - executionTime: Date.now() - startedAt, - timestamp: new Date(), - }; -} - -function warning( - checkId: string, - message: string, - details: string, - startedAt: number, -): types.IHealthCheckResult { - return { - checkId, - status: "warning", - severity: SEVERITY_WARNING, - message, - details, - executionTime: Date.now() - startedAt, - timestamp: new Date(), - }; -} - -/** - * Fails when an install produced zero files — typically means the installer's - * filter logic ate every entry (e.g. directory-only filter mis-applied). - */ -const modHasFilesCheck: types.IModHealthCheck = { - id: "xrebirth-mod-has-files", - name: "X Rebirth — mod has files", - description: "Verifies that the installer produced at least one file.", - category: CATEGORY, - severity: SEVERITY_WARNING, - triggers: TRIGGERS, - checkMod: async (_api, mod) => { - const startedAt = Date.now(); - if (mod.files.length === 0) { - return warning( - "xrebirth-mod-has-files", - "Installer produced no files", - "An installer matched but emitted zero file instructions.", - startedAt, - ); - } - return passed("xrebirth-mod-has-files", "Install output has at least one file", startedAt); - }, -}; - -/** - * For content.xml mods: the install path always emits a `customFileName` - * attribute from the XML's `name` field. Its absence after install indicates - * the install function didn't run end-to-end (e.g. silent throw). - */ -const contentXmlCustomFileNameCheck: types.IModHealthCheck = { - id: "xrebirth-content-xml-customFileName", - name: "X Rebirth — content.xml carries customFileName", - description: "Verifies that content.xml mods record their declared name.", - category: CATEGORY, - severity: SEVERITY_WARNING, - triggers: TRIGGERS, - checkMod: async (_api, mod) => { - const startedAt = Date.now(); - if (!isContentXmlMod(mod)) { - return passed( - "xrebirth-content-xml-customFileName", - "Not a content.xml mod; check not applicable", - startedAt, - ); - } - if (mod.attributes.customFileName === undefined) { - return warning( - "xrebirth-content-xml-customFileName", - "content.xml mod missing customFileName attribute", - "The content.xml installer always emits customFileName from the XML's name field. " + - "Its absence means the install path didn't complete.", - startedAt, - ); - } - return passed( - "xrebirth-content-xml-customFileName", - "content.xml mod has customFileName", - startedAt, - ); - }, -}; - -/** - * The mod must look like *some* recognisable X Rebirth shape: a content.xml - * mod, a drop-in matching the game's stopPatterns, or one of the tagged - * non-content modTypes (savegame, utility, shader, etc.). Otherwise the - * installer matched something but it isn't actually X Rebirth content. - */ -const modShapeRecognisedCheck: types.IModHealthCheck = { - id: "xrebirth-mod-shape-recognised", - name: "X Rebirth — mod has a recognisable shape", - description: - "Verifies the install output is content.xml, matches stopPatterns, or is tagged with a known modType.", - category: CATEGORY, - severity: SEVERITY_WARNING, - triggers: TRIGGERS, - checkMod: async (_api, mod) => { - const startedAt = Date.now(); - if (isContentXmlMod(mod)) { - return passed("xrebirth-mod-shape-recognised", "Recognised as content.xml mod", startedAt); - } - const modType = mod.attributes.modType as string | undefined; - if (modType !== undefined && TAGGED_NON_CONTENT_XML.has(modType)) { - return passed( - "xrebirth-mod-shape-recognised", - `Recognised by modType: ${modType}`, - startedAt, - ); - } - if (mod.files.some((f) => STOP_PATTERN_REGEXES.some((re) => re.test(f)))) { - return passed("xrebirth-mod-shape-recognised", "Recognised by stopPatterns match", startedAt); - } - return warning( - "xrebirth-mod-shape-recognised", - "Install output has no recognisable X Rebirth shape", - "No content.xml, no stop-pattern matches, and no recognised modType.", - startedAt, - ); - }, -}; - -export const healthChecks: types.IModHealthCheck[] = [ - modHasFilesCheck, - contentXmlCustomFileNameCheck, - modShapeRecognisedCheck, -]; diff --git a/extensions/games/game-xrebirth/src/hooks.test.ts b/extensions/games/game-xrebirth/src/hooks.test.ts new file mode 100644 index 0000000000..3d7d4bcb7c --- /dev/null +++ b/extensions/games/game-xrebirth/src/hooks.test.ts @@ -0,0 +1,172 @@ +import path from "node:path"; + +import { setReadFileResolver } from "@vortex/extension-test-mocks"; +import { describe, test, it, expect, beforeEach } from "vitest"; + +import { + installContentXml, + modHasFilesCheck, + contentXmlCustomFileNameCheck, + modShapeRecognisedCheck, +} from "./hooks"; + +// --------------------------------------------------------------------------- +// installContentXml +// --------------------------------------------------------------------------- + +describe("installContentXml", () => { + beforeEach(() => { + setReadFileResolver(async () => Buffer.alloc(0)); + }); + + it("emits attribute + copy instructions for a well-formed content.xml", async () => { + setReadFileResolver( + async () => + '' + + '', + ); + + const result = await installContentXml( + ["wrap/content.xml", "wrap/data.cat", "wrap/dir/"], + "/install/dest", + ); + + expect(result.instructions).toContainEqual({ + type: "attribute", + key: "customFileName", + value: "My Mod", + }); + expect(result.instructions).toContainEqual({ type: "attribute", key: "sticky", value: true }); + expect(result.instructions).toContainEqual({ type: "attribute", key: "author", value: "me" }); + + const copyInstructions = result.instructions.filter((i) => i.type === "copy"); + expect(copyInstructions).toEqual([ + { type: "copy", source: "wrap/content.xml", destination: path.join("my-mod", "content.xml") }, + { type: "copy", source: "wrap/data.cat", destination: path.join("my-mod", "data.cat") }, + ]); + }); + + it("throws DataInvalid for malformed XML", async () => { + setReadFileResolver(async () => "<<>>"); + await expect(installContentXml(["content.xml"], "/dest")).rejects.toMatchObject({ + name: "DataInvalid", + }); + }); + + it("throws DataInvalid when content.xml has no id attribute", async () => { + setReadFileResolver(async () => ''); + await expect(installContentXml(["content.xml"], "/dest")).rejects.toMatchObject({ + name: "DataInvalid", + }); + }); +}); + +// --------------------------------------------------------------------------- +// health checks +// --------------------------------------------------------------------------- + +const ctx = { + modId: "test", + files: [] as string[], + readFile: async () => Buffer.alloc(0), + attributes: {} as Record, +}; + +// The health checks ignore the api argument; derive its type from checkMod so +// the unused stub stays correctly typed. +const STUB_API = {} as unknown as Parameters[0]; + +describe("modHasFilesCheck", () => { + test("warns when installer produced no files", async () => { + const result = await modHasFilesCheck.checkMod(STUB_API, { ...ctx, files: [] }); + expect(result.status).toBe("warning"); + expect(result.message).toMatch(/no files/i); + }); + + test("passes when there is at least one file", async () => { + const result = await modHasFilesCheck.checkMod(STUB_API, { ...ctx, files: ["a/b.xml"] }); + expect(result.status).toBe("passed"); + }); +}); + +describe("contentXmlCustomFileNameCheck", () => { + test("not applicable when not a content.xml mod", async () => { + const result = await contentXmlCustomFileNameCheck.checkMod(STUB_API, { + ...ctx, + files: ["readme.txt"], + attributes: {}, + }); + expect(result.status).toBe("passed"); + expect(result.message).toMatch(/not applicable/i); + }); + + test("warns when content.xml mod is missing customFileName", async () => { + const result = await contentXmlCustomFileNameCheck.checkMod(STUB_API, { + ...ctx, + files: ["mod/content.xml"], + attributes: {}, + }); + expect(result.status).toBe("warning"); + expect(result.message).toMatch(/missing customFileName/i); + }); + + test("passes when content.xml mod has customFileName", async () => { + const result = await contentXmlCustomFileNameCheck.checkMod(STUB_API, { + ...ctx, + files: ["mod/content.xml"], + attributes: { customFileName: "Awesome Mod" }, + }); + expect(result.status).toBe("passed"); + }); + + test("detects content.xml at any depth, case-insensitive", async () => { + const result = await contentXmlCustomFileNameCheck.checkMod(STUB_API, { + ...ctx, + files: ["deep/path/Content.XML"], + attributes: {}, + }); + expect(result.status).toBe("warning"); + }); +}); + +describe("modShapeRecognisedCheck", () => { + test("recognised as content.xml mod", async () => { + const result = await modShapeRecognisedCheck.checkMod(STUB_API, { + ...ctx, + files: ["a/content.xml"], + attributes: {}, + }); + expect(result.status).toBe("passed"); + expect(result.message).toMatch(/content\.xml/); + }); + + test("recognised by tagged modType", async () => { + const result = await modShapeRecognisedCheck.checkMod(STUB_API, { + ...ctx, + files: ["tool.exe"], + attributes: { modType: "xrebirth-utility" }, + }); + expect(result.status).toBe("passed"); + expect(result.message).toContain("xrebirth-utility"); + }); + + test("recognised by stopPattern match", async () => { + const result = await modShapeRecognisedCheck.checkMod(STUB_API, { + ...ctx, + files: ["data.cat"], + attributes: {}, + }); + expect(result.status).toBe("passed"); + expect(result.message).toMatch(/stopPatterns/); + }); + + test("warns when nothing recognised", async () => { + const result = await modShapeRecognisedCheck.checkMod(STUB_API, { + ...ctx, + files: ["random.bin"], + attributes: {}, + }); + expect(result.status).toBe("warning"); + }); +}); diff --git a/extensions/games/game-xrebirth/src/hooks.ts b/extensions/games/game-xrebirth/src/hooks.ts new file mode 100644 index 0000000000..6a8d243044 --- /dev/null +++ b/extensions/games/game-xrebirth/src/hooks.ts @@ -0,0 +1,273 @@ +/** + * Imperative hooks for the X Rebirth GDL extension. The declarative parts of + * the extension live in game.yaml; this file holds the two things GDL can't + * express declaratively: + * + * - installContentXml: the canonical content.xml installer, which parses the + * XML and emits attribute instructions. Referenced by game.yaml's + * `install: { hook: installContentXml }`. + * - the three in-game health checks, referenced by game.yaml's `diagnostics:` + * block and registered via context.registerHealthCheck. + */ +import path from "path"; + +import { fs, types, util } from "@nexusmods/vortex-api"; +import { parseStringPromise } from "xml2js"; + +/** Mod type ids, mirrored from game.yaml's modTypes block. */ +const XREBIRTH_MOD_TYPES = { + savegame: "xrebirth-savegame", + shaderInjector: "xrebirth-shader-injector", + utility: "xrebirth-utility", + dropIn: "xrebirth-dropin", + savePatch: "xrebirth-save-patch", + documentation: "xrebirth-documentation", +} as const; + +/** + * Drop-in stop patterns, mirrored from game.yaml's stopPatterns block (regex + * sources). Used by the mod-shape health check to recognise drop-in content. + */ +const XREBIRTH_STOP_PATTERNS: string[] = [ + "[^/]*\\.cat$", + "[^/]*\\.dat$", + "(^|/)t/[^/]+\\.xml$", + "(^|/)lang\\.dat$", + "(^|/)assets/.+", + "(^|/)libraries/.+\\.xml$", + "(^|/)maps/.+\\.xml$", + "(^|/)md/.+\\.xml$", + "(^|/)cinematics/.+", + "(^|/)aiscripts/.+\\.xml$", + "(^|/)voice-[^/]+/.+\\.(ogg|wav)$", + "(^|/)ui/.+", + "(^|/)sfx/.+", + "[^/]*\\.cur$", + "[^/]*\\.(ogg|mp3|wav)$", + "[^/]*\\.(mkv|mp4|webm)$", + "[^/]*\\.ini$", +]; + +// --------------------------------------------------------------------------- +// content.xml installer +// --------------------------------------------------------------------------- + +/** + * Custom install hook for the canonical content.xml mod shape. Parses the XML + * at install time and emits attribute instructions (customFileName, etc.) plus + * copy instructions, deploying the mod under `extensions/`. + * + * GDL gates support via the `when: { hasFile: "**\/content.xml" }` predicate, so + * this only runs when a content.xml is present. + */ +export async function installContentXml( + files: string[], + destinationPath: string, +): Promise { + const contentPath = files.find((file) => path.basename(file) === "content.xml")!; + const basePath = path.dirname(contentPath); + + const data = await fs.readFileAsync(path.join(destinationPath, contentPath), { + encoding: "utf8", + }); + + let parsed: Record; + try { + parsed = await parseStringPromise(data); + } catch (err) { + throw new util.DataInvalid("content.xml invalid: " + err.message); + } + + const attrs = (parsed?.content as Record)?.$ as + | Record + | undefined; + + const outputPath = attrs?.id; + if (outputPath === undefined) { + throw new util.DataInvalid("invalid or unsupported content.xml"); + } + + const attrInstructions: types.IInstruction[] = Object.entries({ + customFileName: attrs?.name?.trim(), + description: attrs?.description, + sticky: attrs?.save === "true", + author: attrs?.author, + version: attrs?.version, + }).map(([key, value]) => ({ type: "attribute" as const, key, value })); + + // Archive entries may arrive with `/` or `\` separators depending on the + // extraction backend; accept either when matching the wrapping dir. + const isDir = (f: string): boolean => f.endsWith("/") || f.endsWith("\\"); + const isUnderBase = (f: string): boolean => { + if (!f.startsWith(basePath)) return false; + const sep = f.charAt(basePath.length); + return sep === "/" || sep === "\\"; + }; + const copyInstructions: types.IInstruction[] = files + .filter((file) => isUnderBase(file) && !isDir(file)) + .map((file) => ({ + type: "copy" as const, + source: file, + destination: path.join(outputPath, file.substring(basePath.length + 1)), + })); + + return { instructions: attrInstructions.concat(copyInstructions) }; +} + +// --------------------------------------------------------------------------- +// health checks +// --------------------------------------------------------------------------- + +const TAGGED_NON_CONTENT_XML = new Set([ + XREBIRTH_MOD_TYPES.savegame, + XREBIRTH_MOD_TYPES.shaderInjector, + XREBIRTH_MOD_TYPES.utility, + XREBIRTH_MOD_TYPES.documentation, + XREBIRTH_MOD_TYPES.savePatch, +]); + +const CATEGORY = types.HealthCheckCategory.Mods; +const TRIGGERS: types.HealthCheckTrigger[] = [ + types.HealthCheckTrigger.ModsChanged, + types.HealthCheckTrigger.Manual, +]; +const SEVERITY_INFO = types.HealthCheckSeverity.Info; +const SEVERITY_WARNING = types.HealthCheckSeverity.Warning; + +// Hoisted: the stopPatterns list is constant, so compile once instead of every +// check invocation. +const STOP_PATTERN_REGEXES = util.compileStopPatterns(XREBIRTH_STOP_PATTERNS); + +function isContentXmlMod(mod: types.IModCheckContext): boolean { + return mod.files.some((f) => path.basename(f).toLowerCase() === "content.xml"); +} + +function passed(checkId: string, message: string, startedAt: number): types.IHealthCheckResult { + return { + checkId, + status: "passed", + severity: SEVERITY_INFO, + message, + executionTime: Date.now() - startedAt, + timestamp: new Date(), + }; +} + +function warning( + checkId: string, + message: string, + details: string, + startedAt: number, +): types.IHealthCheckResult { + return { + checkId, + status: "warning", + severity: SEVERITY_WARNING, + message, + details, + executionTime: Date.now() - startedAt, + timestamp: new Date(), + }; +} + +/** + * Fails when an install produced zero files - typically means the installer's + * filter logic ate every entry (e.g. directory-only filter mis-applied). + */ +export const modHasFilesCheck: types.IModHealthCheck = { + id: "xrebirth-mod-has-files", + name: "X Rebirth - mod has files", + description: "Verifies that the installer produced at least one file.", + category: CATEGORY, + severity: SEVERITY_WARNING, + triggers: TRIGGERS, + checkMod: async (_api, mod) => { + const startedAt = Date.now(); + if (mod.files.length === 0) { + return warning( + "xrebirth-mod-has-files", + "Installer produced no files", + "An installer matched but emitted zero file instructions.", + startedAt, + ); + } + return passed("xrebirth-mod-has-files", "Install output has at least one file", startedAt); + }, +}; + +/** + * For content.xml mods: the install path always emits a `customFileName` + * attribute from the XML's `name` field. Its absence after install indicates + * the install function didn't run end-to-end (e.g. silent throw). + */ +export const contentXmlCustomFileNameCheck: types.IModHealthCheck = { + id: "xrebirth-content-xml-customFileName", + name: "X Rebirth - content.xml carries customFileName", + description: "Verifies that content.xml mods record their declared name.", + category: CATEGORY, + severity: SEVERITY_WARNING, + triggers: TRIGGERS, + checkMod: async (_api, mod) => { + const startedAt = Date.now(); + if (!isContentXmlMod(mod)) { + return passed( + "xrebirth-content-xml-customFileName", + "Not a content.xml mod; check not applicable", + startedAt, + ); + } + if (mod.attributes.customFileName === undefined) { + return warning( + "xrebirth-content-xml-customFileName", + "content.xml mod missing customFileName attribute", + "The content.xml installer always emits customFileName from the XML's name field. " + + "Its absence means the install path didn't complete.", + startedAt, + ); + } + return passed( + "xrebirth-content-xml-customFileName", + "content.xml mod has customFileName", + startedAt, + ); + }, +}; + +/** + * The mod must look like *some* recognisable X Rebirth shape: a content.xml + * mod, a drop-in matching the game's stopPatterns, or one of the tagged + * non-content modTypes (savegame, utility, shader, etc.). Otherwise the + * installer matched something but it isn't actually X Rebirth content. + */ +export const modShapeRecognisedCheck: types.IModHealthCheck = { + id: "xrebirth-mod-shape-recognised", + name: "X Rebirth - mod has a recognisable shape", + description: + "Verifies the install output is content.xml, matches stopPatterns, or is tagged with a known modType.", + category: CATEGORY, + severity: SEVERITY_WARNING, + triggers: TRIGGERS, + checkMod: async (_api, mod) => { + const startedAt = Date.now(); + if (isContentXmlMod(mod)) { + return passed("xrebirth-mod-shape-recognised", "Recognised as content.xml mod", startedAt); + } + const modType = mod.attributes.modType as string | undefined; + if (modType !== undefined && TAGGED_NON_CONTENT_XML.has(modType)) { + return passed( + "xrebirth-mod-shape-recognised", + `Recognised by modType: ${modType}`, + startedAt, + ); + } + if (mod.files.some((f) => STOP_PATTERN_REGEXES.some((re) => re.test(f)))) { + return passed("xrebirth-mod-shape-recognised", "Recognised by stopPatterns match", startedAt); + } + return warning( + "xrebirth-mod-shape-recognised", + "Install output has no recognisable X Rebirth shape", + "No content.xml, no stop-pattern matches, and no recognised modType.", + startedAt, + ); + }, +}; diff --git a/extensions/games/game-xrebirth/src/index.js b/extensions/games/game-xrebirth/src/index.js deleted file mode 100644 index 962175a89b..0000000000 --- a/extensions/games/game-xrebirth/src/index.js +++ /dev/null @@ -1,117 +0,0 @@ -const Promise = require("bluebird"); -const { parseStringPromise } = require("xml2js"); -const path = require("path"); -const { fs, log, util } = require("@nexusmods/vortex-api"); - -function findGame() { - return util.steam.findByName("X Rebirth").then((game) => game.gamePath); -} - -function testSupported(files, gameId) { - if (gameId !== "xrebirth") { - return Promise.resolve({ supported: false }); - } - - const contentPath = files.find((file) => path.basename(file) === "content.xml"); - return Promise.resolve({ - supported: contentPath !== undefined, - requiredFiles: [contentPath], - }); -} - -function install(files, destinationPath, gameId, progressDelegate) { - const contentPath = files.find((file) => path.basename(file) === "content.xml"); - const basePath = path.dirname(contentPath); - - let outputPath = basePath; - - return fs - .readFileAsync(path.join(destinationPath, contentPath), { encoding: "utf8" }) - .then(async (data) => { - let parsed; - try { - parsed = await parseStringPromise(data); - } catch (err) { - return Promise.reject(new util.DataInvalid("content.xml invalid: " + err.message)); - } - const attrInstructions = []; - - const getAttr = (key) => { - try { - return parsed?.content?.$?.[key]; - } catch (err) { - log("info", "attribute missing in content.xml", { key }); - } - }; - - outputPath = getAttr("id"); - if (outputPath === undefined) { - return Promise.reject(new util.DataInvalid("invalid or unsupported content.xml")); - } - attrInstructions.push({ - type: "attribute", - key: "customFileName", - value: getAttr("name").trim(), - }); - attrInstructions.push({ - type: "attribute", - key: "description", - value: getAttr("description"), - }); - attrInstructions.push({ - type: "attribute", - key: "sticky", - value: getAttr("save") === "true", - }); - attrInstructions.push({ - trype: "attribute", - key: "author", - value: getAttr("author"), - }); - attrInstructions.push({ - type: "attribute", - key: "version", - value: getAttr("version"), - }); - return Promise.resolve(attrInstructions); - }) - .then((attrInstructions) => { - let instructions = attrInstructions.concat( - files - .filter((file) => file.startsWith(basePath + path.sep) && !file.endsWith(path.sep)) - .map((file) => ({ - type: "copy", - source: file, - destination: path.join(outputPath, file.substring(basePath.length + 1)), - })), - ); - return { instructions }; - }); -} - -function main(context) { - context.registerGame({ - id: "xrebirth", - name: "X Rebirth", - mergeMods: true, - queryPath: findGame, - queryModPath: () => "extensions", - logo: "gameart.jpg", - executable: () => "XRebirth.exe", - requiredFiles: ["XRebirth.exe"], - environment: { - SteamAPPId: "2870", - }, - details: { - steamAppId: 2870, - }, - }); - - context.registerInstaller("xrebirth", 50, testSupported, install); - - return true; -} - -module.exports = { - default: main, -}; diff --git a/extensions/games/game-xrebirth/src/index.ts b/extensions/games/game-xrebirth/src/index.ts deleted file mode 100644 index 9d65633fd2..0000000000 --- a/extensions/games/game-xrebirth/src/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { util } from "@nexusmods/vortex-api"; -import type { types } from "@nexusmods/vortex-api"; - -import { healthChecks } from "./diagnostic"; -import { - XREBIRTH_CONTENT_XML_PRIORITY, - XREBIRTH_GAME_ID, - XREBIRTH_INSTALLER_SPECS, - installContentXml, - testContentXml, -} from "./installers"; -import { XREBIRTH_STOP_PATTERNS } from "./stopPatterns"; - -function main(context: types.IExtensionContext): boolean { - context.registerGame({ - id: XREBIRTH_GAME_ID, - name: "X Rebirth", - queryArgs: { steam: "2870" }, - queryModPath: () => "extensions", - logo: "gameart.webp", - executable: () => "XRebirth.exe", - requiredFiles: ["XRebirth.exe"], - details: { stopPatterns: XREBIRTH_STOP_PATTERNS }, - }); - - // The canonical content.xml installer is hand-written: it parses XML and - // emits attribute instructions, which the declarative table can't express. - context.registerInstaller( - XREBIRTH_GAME_ID, - XREBIRTH_CONTENT_XML_PRIORITY, - testContentXml, - installContentXml, - ); - - // Everything else is a config-driven match → copy → setmodtype. - util.declareInstallers(context, XREBIRTH_GAME_ID, XREBIRTH_INSTALLER_SPECS); - - for (const check of healthChecks) { - context.registerHealthCheck(check); - } - - return true; -} - -export default main; diff --git a/extensions/games/game-xrebirth/src/installers.test.ts b/extensions/games/game-xrebirth/src/installers.test.ts deleted file mode 100644 index b03215b9db..0000000000 --- a/extensions/games/game-xrebirth/src/installers.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import path from "node:path"; - -import { setReadFileResolver } from "@vortex/extension-test-mocks"; -import { describe, expect, it, beforeEach } from "vitest"; - -import { - XREBIRTH_GAME_ID, - XREBIRTH_INSTALLER_SPECS, - XREBIRTH_MOD_TYPES, - installContentXml, - testContentXml, -} from "./installers"; - -function specById(id: string) { - const spec = XREBIRTH_INSTALLER_SPECS.find((s) => s.id === id); - if (!spec) throw new Error(`no spec with id=${id}`); - return spec; -} - -// Mirror of installerHelpers.ts evaluateMatch — kept tiny on purpose so the -// tests exercise each spec's match config without depending on the renderer -// runtime. `stopPatterns` is excluded because it's delegated to the framework -// and validated by installerHelpers.test.ts. -function evaluateMatch(spec: ReturnType, files: string[]): boolean { - const dataFiles = files.filter((f) => !f.endsWith("/") && !f.endsWith("\\")); - const match = spec.match; - switch (match.kind) { - case "extensions": { - const lower = match.list.map((e) => e.toLowerCase()); - const test = (f: string) => lower.some((ext) => f.toLowerCase().endsWith(ext)); - return match.mode === "all" ? dataFiles.every(test) : dataFiles.some(test); - } - case "regex": { - const test = (f: string) => match.patterns.some((re) => re.test(f)); - return match.mode === "all" ? dataFiles.every(test) : dataFiles.some(test); - } - case "custom": - return match.predicate(files); - default: - throw new Error(`unexpected match kind ${(match as { kind: string }).kind}`); - } -} - -describe("XREBIRTH_INSTALLER_SPECS — shape", () => { - it("declares six specs in ascending priority order", () => { - expect(XREBIRTH_INSTALLER_SPECS.map((s) => s.id)).toEqual([ - "savegame", - "shader-injector", - "utility", - "dropin", - "save-patch", - "documentation", - ]); - const priorities = XREBIRTH_INSTALLER_SPECS.map((s) => s.priority); - expect(priorities).toEqual([...priorities].sort((a, b) => a - b)); - }); - - it("every spec emits a setmodtype from XREBIRTH_MOD_TYPES", () => { - const declared = new Set(Object.values(XREBIRTH_MOD_TYPES)); - for (const spec of XREBIRTH_INSTALLER_SPECS) { - expect(spec.modType).toBeDefined(); - expect(declared.has(spec.modType as never)).toBe(true); - } - }); -}); - -describe("XREBIRTH_INSTALLER_SPECS — savegame", () => { - const spec = specById("savegame"); - - it.each([["save_001.xml"], ["save_999.xml"], ["quicksave.xml"], ["nested/save_42.xml"]])( - "matches %s", - (file) => { - expect(evaluateMatch(spec, [file])).toBe(true); - }, - ); - - it.each([ - ["content.xml"], - ["save_001.txt"], - ["notasave_001.xml"], - ["save.xml"], - ["savefoo_1.xml"], - ])("rejects %s", (file) => { - expect(evaluateMatch(spec, [file])).toBe(false); - }); -}); - -describe("XREBIRTH_INSTALLER_SPECS — shader-injector", () => { - const spec = specById("shader-injector"); - - it.each([ - ["d3d9.dll"], - ["dxgi.dll"], - ["d3d9.ini"], - ["SweetFX/Shaders/foo.fx"], - ["SweetFX_settings.txt"], - ["reshade-shaders/Shaders/SMAA.fx"], - ["ReShade/foo.ini"], - ])("matches %s", (file) => { - expect(evaluateMatch(spec, [file])).toBe(true); - }); - - it.each([["readme.txt"], ["content.xml"], ["bin/Game.exe"]])("rejects %s", (file) => { - expect(evaluateMatch(spec, [file])).toBe(false); - }); -}); - -describe("XREBIRTH_INSTALLER_SPECS — utility", () => { - const spec = specById("utility"); - - it("matches archives containing any .exe", () => { - expect(evaluateMatch(spec, ["tool.exe", "readme.txt"])).toBe(true); - expect(evaluateMatch(spec, ["nested/dir/tool.EXE"])).toBe(true); - }); - - it("rejects archives without an .exe", () => { - expect(evaluateMatch(spec, ["content.xml", "data.cat"])).toBe(false); - }); -}); - -describe("XREBIRTH_INSTALLER_SPECS — dropin", () => { - const spec = specById("dropin"); - - it("uses the stopPatterns matcher (delegated to framework)", () => { - expect(spec.match.kind).toBe("stopPatterns"); - expect(spec.install.stripCommonRoot).toBe(true); - }); -}); - -describe("XREBIRTH_INSTALLER_SPECS — save-patch", () => { - const spec = specById("save-patch"); - - it("accepts archives with only .xml + .txt and at least one .xml", () => { - expect(evaluateMatch(spec, ["patch.xml", "notes.txt"])).toBe(true); - expect(evaluateMatch(spec, ["a.xml", "b.xml"])).toBe(true); - }); - - it("rejects archives missing an .xml file", () => { - expect(evaluateMatch(spec, ["a.txt", "b.txt"])).toBe(false); - }); - - it("rejects archives with non-xml/txt files", () => { - expect(evaluateMatch(spec, ["a.xml", "b.dat"])).toBe(false); - }); - - it("rejects empty archives", () => { - expect(evaluateMatch(spec, [])).toBe(false); - }); - - it("ignores directory entries when judging emptiness", () => { - expect(evaluateMatch(spec, ["dir/"])).toBe(false); - }); -}); - -describe("XREBIRTH_INSTALLER_SPECS — documentation", () => { - const spec = specById("documentation"); - - it("accepts archives where every file is a doc extension", () => { - expect(evaluateMatch(spec, ["readme.md", "screenshots/preview.png"])).toBe(true); - }); - - it("rejects archives with any non-doc file", () => { - expect(evaluateMatch(spec, ["readme.md", "patch.xml"])).toBe(false); - }); -}); - -describe("testContentXml", () => { - it("returns supported=false for wrong gameId", async () => { - const result = await testContentXml(["content.xml"], "skyrim"); - expect(result).toEqual({ supported: false, requiredFiles: [] }); - }); - - it("returns supported=true with content.xml at the root", async () => { - const result = await testContentXml(["content.xml", "extras/x.txt"], XREBIRTH_GAME_ID); - expect(result.supported).toBe(true); - expect(result.requiredFiles).toEqual(["content.xml"]); - }); - - it("finds a nested content.xml", async () => { - const result = await testContentXml( - ["wrap/mod/content.xml", "wrap/mod/data.cat"], - XREBIRTH_GAME_ID, - ); - expect(result.supported).toBe(true); - expect(result.requiredFiles).toEqual(["wrap/mod/content.xml"]); - }); - - it("rejects archives without a content.xml", async () => { - const result = await testContentXml(["data.cat", "data.dat"], XREBIRTH_GAME_ID); - expect(result).toEqual({ supported: false, requiredFiles: [] }); - }); -}); - -describe("installContentXml", () => { - beforeEach(() => { - setReadFileResolver(async () => Buffer.alloc(0)); - }); - - it("emits attribute + copy instructions for a well-formed content.xml", async () => { - setReadFileResolver( - async () => - '' + - '', - ); - - const result = await installContentXml( - ["wrap/content.xml", "wrap/data.cat", "wrap/dir/"], - "/install/dest", - ); - - expect(result.instructions).toContainEqual({ - type: "attribute", - key: "customFileName", - value: "My Mod", - }); - expect(result.instructions).toContainEqual({ type: "attribute", key: "sticky", value: true }); - expect(result.instructions).toContainEqual({ type: "attribute", key: "author", value: "me" }); - - const copyInstructions = result.instructions.filter((i) => i.type === "copy"); - expect(copyInstructions).toEqual([ - { - type: "copy", - source: "wrap/content.xml", - destination: path.join("my-mod", "content.xml"), - }, - { - type: "copy", - source: "wrap/data.cat", - destination: path.join("my-mod", "data.cat"), - }, - ]); - }); - - it("throws DataInvalid for malformed XML", async () => { - setReadFileResolver(async () => "<<>>"); - await expect(installContentXml(["content.xml"], "/dest")).rejects.toMatchObject({ - name: "DataInvalid", - }); - }); - - it("throws DataInvalid when content.xml has no id attribute", async () => { - setReadFileResolver(async () => ''); - await expect(installContentXml(["content.xml"], "/dest")).rejects.toMatchObject({ - name: "DataInvalid", - }); - }); -}); diff --git a/extensions/games/game-xrebirth/src/installers.ts b/extensions/games/game-xrebirth/src/installers.ts deleted file mode 100644 index de18adf78e..0000000000 --- a/extensions/games/game-xrebirth/src/installers.ts +++ /dev/null @@ -1,219 +0,0 @@ -import path from "path"; - -import { fs, util } from "@nexusmods/vortex-api"; -import type { types } from "@nexusmods/vortex-api"; -import { parseStringPromise } from "xml2js"; - -export const XREBIRTH_GAME_ID = "xrebirth"; - -/** - * Single source of truth for modType IDs registered by this extension. Mirrored - * by the installer ids in the spec table below so deployment can route on - * modType without string drift. - */ -export const XREBIRTH_MOD_TYPES = { - savegame: "xrebirth-savegame", - shaderInjector: "xrebirth-shader-injector", - utility: "xrebirth-utility", - dropIn: "xrebirth-dropin", - savePatch: "xrebirth-save-patch", - documentation: "xrebirth-documentation", -} as const; - -/** - * Installer priority slots. Vortex dispatches lower numbers first, so the - * most specific match must have the lowest number. The ordering rationale: - * - * contentXml (50) — canonical X Rebirth mod shape; XML-driven and - * emits attribute instructions. Always tried first. - * savegame (60) — narrow regex (save_NNN.xml / quicksave.xml) that - * wouldn't accidentally swallow other XML mods. - * shaderInjector(65) — d3d9/dxgi/SweetFX/ReShade markers; specific enough - * to beat the generic .exe utility match. - * utility (70) — .exe presence; broad, but more specific than the - * stopPattern drop-in fallback. - * dropIn (75) — game-shape match via the registered stopPatterns; - * covers everything that "looks like an X Rebirth - * drop-in" without an .exe or shader marker. - * savePatch (80) — runs after dropIn because XML/TXT-only archives - * that ALSO match a stop-pattern (rare but possible) - * should deploy as a drop-in instead. - * documentation (90) — pure-docs catch-all; only fires when no installer - * above accepts the archive. - */ -const PRIORITIES = { - contentXml: 50, - savegame: 60, - shaderInjector: 65, - utility: 70, - dropIn: 75, - savePatch: 80, - documentation: 90, -} as const; - -/** SweetFX / ReShade shader-injector signatures. */ -const SHADER_MARKERS = [ - /(^|\/)d3d9\.dll$/i, - /(^|\/)dxgi\.dll$/i, - /(^|\/)d3d9\.ini$/i, - /(^|\/)SweetFX([\\/]|_)/i, - /(^|\/)reshade-shaders\//i, - /(^|\/)ReShade\//i, -]; - -/** Matches `save_NNN.xml` and `quicksave.xml` filenames at any depth. */ -const SAVE_FILE_RE = /(^|\/)(quicksave|save_\d+)\.xml$/i; - -const DOC_EXTENSIONS = [ - ".pdf", - ".png", - ".jpg", - ".jpeg", - ".gif", - ".svg", - ".xlsx", - ".xls", - ".docx", - ".doc", - ".odt", - ".ods", - ".md", - ".rtf", -]; - -/** - * Declarative installer table for X Rebirth. Priority ordering is documented - * on the PRIORITIES constant above. - */ -export const XREBIRTH_INSTALLER_SPECS: types.IInstallerSpec[] = [ - { - id: "savegame", - priority: PRIORITIES.savegame, - modType: XREBIRTH_MOD_TYPES.savegame, - match: { kind: "regex", patterns: [SAVE_FILE_RE], mode: "any" }, - install: { stripCommonRoot: false }, - }, - { - id: "shader-injector", - priority: PRIORITIES.shaderInjector, - modType: XREBIRTH_MOD_TYPES.shaderInjector, - match: { kind: "regex", patterns: SHADER_MARKERS, mode: "any" }, - install: { stripCommonRoot: true }, - }, - { - id: "utility", - priority: PRIORITIES.utility, - modType: XREBIRTH_MOD_TYPES.utility, - match: { kind: "extensions", list: [".exe"], mode: "any" }, - install: { stripCommonRoot: true }, - }, - { - id: "dropin", - priority: PRIORITIES.dropIn, - modType: XREBIRTH_MOD_TYPES.dropIn, - // Files match one of the game's `details.stopPatterns` — translations under - // `t/`, `.cat`/`.dat` archives, voice packs, etc. - match: { kind: "stopPatterns" }, - install: { stripCommonRoot: true }, - }, - { - id: "save-patch", - priority: PRIORITIES.savePatch, - modType: XREBIRTH_MOD_TYPES.savePatch, - // Every file is .xml or .txt and at least one is .xml. Custom predicate - // because the "all of these AND at least one of those" shape doesn't fit - // the simpler match kinds. - match: { - kind: "custom", - predicate: (files: string[]): boolean => { - const data = files.filter((f) => !f.endsWith(path.sep)); - return ( - data.length > 0 && - data.every((f) => /\.(xml|txt)$/i.test(f)) && - data.some((f) => /\.xml$/i.test(f)) - ); - }, - }, - install: { stripCommonRoot: false }, - }, - { - id: "documentation", - priority: PRIORITIES.documentation, - modType: XREBIRTH_MOD_TYPES.documentation, - match: { kind: "extensions", list: DOC_EXTENSIONS, mode: "all" }, - install: { stripCommonRoot: true }, - }, -]; - -/** - * `testSupported` for the canonical content.xml installer. Parses the XML at - * install time, so it stays hand-written rather than going through the - * declarative spec table. - */ -export function testContentXml(files: string[], gameId: string): Promise { - if (gameId !== XREBIRTH_GAME_ID) { - return Promise.resolve({ supported: false, requiredFiles: [] }); - } - const contentPath = files.find((file) => path.basename(file) === "content.xml"); - return Promise.resolve({ - supported: contentPath !== undefined, - requiredFiles: contentPath !== undefined ? [contentPath] : [], - }); -} - -export async function installContentXml( - files: string[], - destinationPath: string, -): Promise { - const contentPath = files.find((file) => path.basename(file) === "content.xml")!; - const basePath = path.dirname(contentPath); - - const data = await fs.readFileAsync(path.join(destinationPath, contentPath), { - encoding: "utf8", - }); - - let parsed: Record; - try { - parsed = await parseStringPromise(data); - } catch (err) { - throw new util.DataInvalid("content.xml invalid: " + err.message); - } - - const attrs = (parsed?.content as Record)?.$ as - | Record - | undefined; - - const outputPath = attrs?.id; - if (outputPath === undefined) { - throw new util.DataInvalid("invalid or unsupported content.xml"); - } - - const attrInstructions: types.IInstruction[] = Object.entries({ - customFileName: attrs?.name?.trim(), - description: attrs?.description, - sticky: attrs?.save === "true", - author: attrs?.author, - version: attrs?.version, - }).map(([key, value]) => ({ type: "attribute" as const, key, value })); - - // Archive entries may arrive with `/` or `\` separators depending on the - // extraction backend; accept either when matching the wrapping dir. - const isDir = (f: string): boolean => f.endsWith("/") || f.endsWith("\\"); - const isUnderBase = (f: string): boolean => { - if (!f.startsWith(basePath)) return false; - const sep = f.charAt(basePath.length); - return sep === "/" || sep === "\\"; - }; - const copyInstructions: types.IInstruction[] = files - .filter((file) => isUnderBase(file) && !isDir(file)) - .map((file) => ({ - type: "copy" as const, - source: file, - destination: path.join(outputPath, file.substring(basePath.length + 1)), - })); - - return { instructions: attrInstructions.concat(copyInstructions) }; -} - -/** Registration priority of the content.xml installer. */ -export const XREBIRTH_CONTENT_XML_PRIORITY = PRIORITIES.contentXml; diff --git a/extensions/games/game-xrebirth/src/stopPatterns.ts b/extensions/games/game-xrebirth/src/stopPatterns.ts deleted file mode 100644 index 835783593b..0000000000 --- a/extensions/games/game-xrebirth/src/stopPatterns.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Regex patterns that identify X Rebirth drop-in mod files (archives meant to - * be extracted into the game root rather than wrapped under - * `extensions//content.xml`). - * - * Also passed to `IGame.details.stopPatterns` so the renderer's `getStopPatterns` - * helper (`installer_fomod_shared/utils/gameSupport.ts:526`) returns them for - * any FOMOD-aware installer that might inspect this game in the future. - * - * Patterns are compiled case-insensitive (see `util.compileStopPatterns`) so - * `.cat` matches `.CAT`. Some entries deliberately overlap with the more - * specific installer matchers in `installers.ts`: - * - the broad `*.ini` rule overlaps with the shader-injector's `d3d9.ini` - * - `ui/.+` / `assets/.+` overlap with the pure-docs `.pdf` / `.md` rule - * In every overlap, the more specific installer (lower priority number) wins. - * See PRIORITIES in installers.ts for the dispatch order. - */ -export const XREBIRTH_STOP_PATTERNS: string[] = [ - // X Rebirth game-data archives. - "[^/]*\\.cat$", - "[^/]*\\.dat$", - // Translation/text files. - "(^|/)t/[^/]+\\.xml$", - // Game language data dropped at root. - "(^|/)lang\\.dat$", - // Standard X Rebirth mod subfolders containing .xml content. - "(^|/)assets/.+", - "(^|/)libraries/.+\\.xml$", - "(^|/)maps/.+\\.xml$", - "(^|/)md/.+\\.xml$", - "(^|/)cinematics/.+", - // AI script overrides. - "(^|/)aiscripts/.+\\.xml$", - // Voice/audio packs (folders typically named voice-L0NN/). - "(^|/)voice-[^/]+/.+\\.(ogg|wav)$", - // UI presentation content. - "(^|/)ui/.+", - // SFX / pre-baked audio dropped at root. - "(^|/)sfx/.+", - // Cursor replacements. - "[^/]*\\.cur$", - // Audio replacements (numbered .ogg files, music .mp3, voice .wav). - "[^/]*\\.(ogg|mp3|wav)$", - // Video replacements (intro videos, cutscenes). - "[^/]*\\.(mkv|mp4|webm)$", - // Standalone configs (ReShade presets, etc.) — accepted as drop-ins. - "[^/]*\\.ini$", -]; diff --git a/game-description-language b/game-description-language new file mode 160000 index 0000000000..e853c207f1 --- /dev/null +++ b/game-description-language @@ -0,0 +1 @@ +Subproject commit e853c207f1f5387ed356fcea6646caa5781b5a5f diff --git a/packages/game-extension-test/src/loadExtension.ts b/packages/game-extension-test/src/loadExtension.ts index 65be363165..50d6cf03d7 100644 --- a/packages/game-extension-test/src/loadExtension.ts +++ b/packages/game-extension-test/src/loadExtension.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import * as path from "node:path"; import type { IGameExtensionTestDescriptor, IModCheckContext } from "./types"; @@ -83,6 +84,7 @@ export interface ILoadedExtension { interface IStubContext { _installers: IInstallerEntry[]; _game: IHarnessGame | undefined; + _healthChecks: IHarnessModHealthCheck[]; registerGame: (game: IHarnessGame) => void; registerInstaller: ( id: string, @@ -90,6 +92,7 @@ interface IStubContext { testSupported: HarnessTestSupported, install: HarnessInstall, ) => void; + registerHealthCheck: (check: IHarnessModHealthCheck) => void; once: (cb: () => void) => void; api: Record; [hook: string]: unknown; @@ -97,9 +100,34 @@ interface IStubContext { export async function loadExtension(extensionDir: string): Promise { const stubContext = makeStubContext(); - const indexPath = path.join(extensionDir, "src", "index.ts"); + + // GDL extensions (those with a game.yaml) have no hand-written src/index.ts; + // their entry is the bundle produced by `gdl build` (dist/index.js), which + // registers the game, installers, and health checks against the context. + // Legacy hand-written extensions load src/index.ts directly. + const isGdl = existsSync(path.join(extensionDir, "game.yaml")); + // For GDL extensions, load the generated TS entry (not the webpack bundle): + // it runs through the harness's module aliases (@nexusmods/vortex-api -> mock, + // @gdl/runtime -> submodule runtime), whereas the bundled CJS would bypass + // them and fail to resolve the types-only @nexusmods/vortex-api package. + const indexPath = isGdl + ? path.join(extensionDir, ".gdl-out", "extension.ts") + : path.join(extensionDir, "src", "index.ts"); + + if (isGdl && !existsSync(indexPath)) { + throw new Error( + `GDL extension ${extensionDir} has a game.yaml but no generated entry at ` + + `.gdl-out/extension.ts. Run \`gdl build\` (the extension's build script) first.`, + ); + } + const mod = await import(indexPath); - const init = mod.default ?? mod.init; + // Resolve the init function across module shapes: ESM default, CommonJS + // module.exports = fn, and webpack commonjs2's nested { default: fn }. + const candidates = [mod.default, mod.init, mod, (mod.default as { default?: unknown })?.default]; + const init = candidates.find((c) => typeof c === "function") as + | ((ctx: unknown) => void) + | undefined; if (typeof init !== "function") { throw new Error(`Extension ${extensionDir} has no default export`); } @@ -116,20 +144,6 @@ export async function loadExtension(extensionDir: string): Promise Date: Tue, 16 Jun 2026 11:08:34 -0700 Subject: [PATCH 02/12] Address code review: build bootstrap, content.xml casing, test ordering - Make GDL extensions build in a fresh clone / CI. The build now goes through scripts/build-gdl-extension.mjs, which installs and builds the self-contained game-description-language submodule (using its own pinned deps) on first build, since Vortex's install does not reach into it. The previous build.mjs assumed the submodule was already installed and built. - Fix a latent crash: the content.xml installer's lookup was case-sensitive while the `hasFile: "**/content.xml"` predicate that selects it is case-insensitive, so an archive with e.g. Content.XML would pass testSupported and then throw in install. Match case-insensitively, in line with the mod-shape health check. - Make the game-extensions test target depend on build (it loads the generated .gdl-out entry) and include game.yaml in its inputs. Regression unchanged: 956 passed, 15 skipped. --- extensions/games/game-xrebirth/build.mjs | 39 ++---------------- extensions/games/game-xrebirth/package.json | 4 ++ extensions/games/game-xrebirth/src/hooks.ts | 5 ++- scripts/build-gdl-extension.mjs | 45 +++++++++++++++++++++ 4 files changed, 56 insertions(+), 37 deletions(-) create mode 100644 scripts/build-gdl-extension.mjs diff --git a/extensions/games/game-xrebirth/build.mjs b/extensions/games/game-xrebirth/build.mjs index bda2e52fc1..c647d46a0d 100644 --- a/extensions/games/game-xrebirth/build.mjs +++ b/extensions/games/game-xrebirth/build.mjs @@ -1,42 +1,9 @@ -import { execFileSync } from "node:child_process"; -import { existsSync, rmSync, cpSync } from "node:fs"; import * as path from "node:path"; -import { pathToFileURL } from "node:url"; + +import { buildGdlExtension } from "../../../scripts/build-gdl-extension.mjs"; // This extension is described declaratively in game.yaml and compiled by the // Game Description Language (GDL) toolchain (the game-description-language // submodule) into a Vortex-loadable dist/index.js. The imperative bits // (content.xml installer, health checks) live in src/hooks.ts. - -const extensionPath = path.resolve(import.meta.dirname); -const gdlPath = path.resolve(extensionPath, "..", "..", "..", "game-description-language"); -const gdlDist = path.join(gdlPath, "dist"); - -// Ensure the GDL toolchain is built (it ships as a submodule; its node_modules -// must already be installed). Build it on demand if dist is missing. -if (!existsSync(path.join(gdlDist, "cli.js"))) { - execFileSync( - "node", - [path.join(gdlPath, "node_modules", "typescript", "bin", "tsc"), "-p", "tsconfig.json"], - { - cwd: gdlPath, - stdio: "inherit", - }, - ); - cpSync(path.join(gdlPath, "src", "templates"), path.join(gdlDist, "templates"), { - recursive: true, - }); - cpSync( - path.join(gdlPath, "src", "bundler", "tsconfig.bundle.json"), - path.join(gdlDist, "bundler", "tsconfig.bundle.json"), - ); -} - -// Clean previous output so stale artifacts never shadow a fresh build. -rmSync(path.join(extensionPath, "dist"), { recursive: true, force: true }); - -// Dynamic import needs a file:// URL for absolute paths on Windows. -const { buildExtension } = await import( - pathToFileURL(path.join(gdlDist, "commands", "build.js")).href -); -await buildExtension({ cwd: extensionPath }); +await buildGdlExtension(path.resolve(import.meta.dirname)); diff --git a/extensions/games/game-xrebirth/package.json b/extensions/games/game-xrebirth/package.json index f01348541a..6494d23aaa 100644 --- a/extensions/games/game-xrebirth/package.json +++ b/extensions/games/game-xrebirth/package.json @@ -39,10 +39,14 @@ }, "test:game-extensions": { "executor": "nx:run-commands", + "dependsOn": [ + "build" + ], "options": { "command": "pnpm --filter @vortex/game-extension-test run start -- --game game-xrebirth" }, "inputs": [ + "{projectRoot}/game.yaml", "{projectRoot}/src/**/*.ts" ], "cache": false diff --git a/extensions/games/game-xrebirth/src/hooks.ts b/extensions/games/game-xrebirth/src/hooks.ts index 6a8d243044..df1487e5a3 100644 --- a/extensions/games/game-xrebirth/src/hooks.ts +++ b/extensions/games/game-xrebirth/src/hooks.ts @@ -64,7 +64,10 @@ export async function installContentXml( files: string[], destinationPath: string, ): Promise { - const contentPath = files.find((file) => path.basename(file) === "content.xml")!; + // Match case-insensitively to agree with the `hasFile: "**/content.xml"` + // predicate that gates this installer (GDL globs are case-insensitive); a + // case-sensitive lookup here would miss e.g. `Content.XML` and crash. + const contentPath = files.find((file) => path.basename(file).toLowerCase() === "content.xml")!; const basePath = path.dirname(contentPath); const data = await fs.readFileAsync(path.join(destinationPath, contentPath), { diff --git a/scripts/build-gdl-extension.mjs b/scripts/build-gdl-extension.mjs new file mode 100644 index 0000000000..5be3fb5b70 --- /dev/null +++ b/scripts/build-gdl-extension.mjs @@ -0,0 +1,45 @@ +import { execSync } from "node:child_process"; +import { existsSync, rmSync } from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, ".."); +const gdlPath = path.join(repoRoot, "game-description-language"); + +/** + * Build a GDL (game.yaml) extension. The game-description-language submodule is + * a self-contained toolchain with its own dependencies and lockfile, so Vortex's + * own `pnpm install` does not reach into it. This ensures the submodule is + * installed and built (using its own pinned deps), then compiles the extension's + * game.yaml to dist/index.js. The install and build steps are skipped once their + * outputs exist, so repeat builds only pay for them once. + */ +export async function buildGdlExtension(extensionPath) { + if (!existsSync(gdlPath)) { + throw new Error( + `GDL submodule not found at ${gdlPath}. ` + + `Run \`git submodule update --init game-description-language\` first.`, + ); + } + + // Vortex's install does not reach into the submodule (it has its own + // workspace), so install its dependencies on first build. + if (!existsSync(path.join(gdlPath, "node_modules"))) { + execSync("pnpm install --frozen-lockfile", { cwd: gdlPath, stdio: "inherit" }); + } + + // Build the GDL CLI/toolchain on first build. + if (!existsSync(path.join(gdlPath, "dist", "cli.js"))) { + execSync("pnpm run build", { cwd: gdlPath, stdio: "inherit" }); + } + + // Clean previous output so stale artifacts never shadow a fresh build. + rmSync(path.join(extensionPath, "dist"), { recursive: true, force: true }); + + // Dynamic import needs a file:// URL for absolute paths on Windows. + const { buildExtension } = await import( + pathToFileURL(path.join(gdlPath, "dist", "commands", "build.js")).href + ); + await buildExtension({ cwd: extensionPath }); +} From e3f8eb13cc452d61911bbde5aa36b4cf7f4542b2 Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Tue, 16 Jun 2026 12:01:25 -0700 Subject: [PATCH 03/12] Revert unneeded @gdl/runtime alias in vitest.config.ts Only vitest.fixtures.config.ts (used by the cli.ts fixture runner) loads GDL extensions and needs the @gdl/runtime alias. The plain vitest.config.ts never resolves that specifier, so the alias was dead. --- packages/game-extension-test/vitest.config.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/packages/game-extension-test/vitest.config.ts b/packages/game-extension-test/vitest.config.ts index f0dc52cfd5..9459008aa4 100644 --- a/packages/game-extension-test/vitest.config.ts +++ b/packages/game-extension-test/vitest.config.ts @@ -1,6 +1,4 @@ import { createRequire } from "node:module"; -import * as path from "node:path"; -import { fileURLToPath } from "node:url"; import { mergeConfig, defineConfig } from "vitest/config"; @@ -9,20 +7,11 @@ import baseConfig from "../../vitest.base.config"; const require_ = createRequire(import.meta.url); const VORTEX_API_MOCK = require_.resolve("@vortex/extension-test-mocks"); -// GDL-generated extensions import the runtime via the `@gdl/runtime` webpack -// alias. The harness loads the generated TS (not the webpack bundle), so map -// the same specifier to the submodule's runtime source. -const here = path.dirname(fileURLToPath(import.meta.url)); -const GDL_RUNTIME = path.resolve(here, "../../game-description-language/src/runtime/index.ts"); - export default mergeConfig( baseConfig, defineConfig({ resolve: { - alias: [ - { find: /^@nexusmods\/vortex-api$/, replacement: VORTEX_API_MOCK }, - { find: /^@gdl\/runtime$/, replacement: GDL_RUNTIME }, - ], + alias: [{ find: /^@nexusmods\/vortex-api$/, replacement: VORTEX_API_MOCK }], }, test: { environment: "node", From 6f3628e91a4157bf33d191e5c73391f6874f36b7 Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Tue, 16 Jun 2026 12:51:05 -0700 Subject: [PATCH 04/12] Remove the game-extension-test harness X Rebirth was the only game that used the harness, and it is now a GDL extension. With the move to GDL, game testing is owned by the GDL toolchain (inline tests and corpus), so the standalone harness is no longer needed. Removes packages/game-extension-test, the X Rebirth opt-in flag and test-descriptor.ts, and the test:game-extensions target. The unit tests in src/hooks.test.ts still cover the content.xml installer and health checks via @vortex/extension-test-mocks, which is kept. --- extensions/games/game-xrebirth/package.json | 20 +- .../game-xrebirth/src/test-descriptor.ts | 42 --- packages/game-extension-test/package.json | 36 --- packages/game-extension-test/src/cli.ts | 41 --- packages/game-extension-test/src/discovery.ts | 40 --- packages/game-extension-test/src/index.ts | 1 - .../game-extension-test/src/loadExtension.ts | 239 ---------------- packages/game-extension-test/src/manifest.ts | 81 ------ .../src/materializeInstall.ts | 39 --- packages/game-extension-test/src/mockApi.ts | 47 ---- .../game-extension-test/src/nexusClient.ts | 260 ------------------ .../src/resolveFixtures.ts | 124 --------- packages/game-extension-test/src/retry.ts | 42 --- .../game-extension-test/src/runFixture.ts | 87 ------ .../game-extension-test/src/runOneFixture.ts | 54 ---- .../src/test-entry.test.ts | 55 ---- packages/game-extension-test/src/types.ts | 90 ------ packages/game-extension-test/src/util.ts | 12 - packages/game-extension-test/tsconfig.json | 18 -- packages/game-extension-test/vitest.config.ts | 21 -- .../vitest.fixtures.config.ts | 41 --- pnpm-lock.yaml | 30 -- 22 files changed, 1 insertion(+), 1419 deletions(-) delete mode 100644 extensions/games/game-xrebirth/src/test-descriptor.ts delete mode 100644 packages/game-extension-test/package.json delete mode 100644 packages/game-extension-test/src/cli.ts delete mode 100644 packages/game-extension-test/src/discovery.ts delete mode 100644 packages/game-extension-test/src/index.ts delete mode 100644 packages/game-extension-test/src/loadExtension.ts delete mode 100644 packages/game-extension-test/src/manifest.ts delete mode 100644 packages/game-extension-test/src/materializeInstall.ts delete mode 100644 packages/game-extension-test/src/mockApi.ts delete mode 100644 packages/game-extension-test/src/nexusClient.ts delete mode 100644 packages/game-extension-test/src/resolveFixtures.ts delete mode 100644 packages/game-extension-test/src/retry.ts delete mode 100644 packages/game-extension-test/src/runFixture.ts delete mode 100644 packages/game-extension-test/src/runOneFixture.ts delete mode 100644 packages/game-extension-test/src/test-entry.test.ts delete mode 100644 packages/game-extension-test/src/types.ts delete mode 100644 packages/game-extension-test/src/util.ts delete mode 100644 packages/game-extension-test/tsconfig.json delete mode 100644 packages/game-extension-test/vitest.config.ts delete mode 100644 packages/game-extension-test/vitest.fixtures.config.ts diff --git a/extensions/games/game-xrebirth/package.json b/extensions/games/game-xrebirth/package.json index 6494d23aaa..5cc0dcd26e 100644 --- a/extensions/games/game-xrebirth/package.json +++ b/extensions/games/game-xrebirth/package.json @@ -13,17 +13,13 @@ "config": { "game": "X Rebirth" }, - "vortex": { - "gameExtensionTest": true - }, "dependencies": { "xml2js": "catalog:" }, "devDependencies": { "@nexusmods/vortex-api": "workspace:*", "@types/xml2js": "catalog:", - "@vortex/extension-test-mocks": "workspace:*", - "@vortex/game-extension-test": "workspace:*" + "@vortex/extension-test-mocks": "workspace:*" }, "nx": { "tags": [ @@ -36,20 +32,6 @@ "typescript", "vortex-api" ] - }, - "test:game-extensions": { - "executor": "nx:run-commands", - "dependsOn": [ - "build" - ], - "options": { - "command": "pnpm --filter @vortex/game-extension-test run start -- --game game-xrebirth" - }, - "inputs": [ - "{projectRoot}/game.yaml", - "{projectRoot}/src/**/*.ts" - ], - "cache": false } } } diff --git a/extensions/games/game-xrebirth/src/test-descriptor.ts b/extensions/games/game-xrebirth/src/test-descriptor.ts deleted file mode 100644 index fd39ca1db7..0000000000 --- a/extensions/games/game-xrebirth/src/test-descriptor.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Per-game test harness descriptor. Consumed by `@vortex/game-extension-test`. - * - * The shape is mirrored from `IGameExtensionTestDescriptor` in - * `@vortex/game-extension-test` to avoid the import dependency cycle during - * normalization. Once the harness package is a devDep, the import can be - * restored. - */ -export const testDescriptor = { - gameId: "xrebirth", - nexusGameDomain: "xrebirth", - fixtures: { - mostPopular: 0, - mostRecent: 0, - oldest: 0, - allCollections: false, - all: true, - } as const, - syntheticContent: { - "content.xml": ({ manifestId }: { manifestId: string }) => - ``, - }, - skipHeuristics: [ - { - reason: "Cheat Engine table (not an X Rebirth mod)", - matches: (files) => files.length === 1 && /\.ct$/i.test(files[0]!), - }, - { - reason: "nested archive — user must extract before installing", - matches: (files) => files.some((f) => /\.(7z|rar|zip)$/i.test(f)), - }, - { - reason: "single instruction text file (not installable content)", - matches: (files) => files.length === 1 && /\.txt$/i.test(files[0]!), - }, - { - reason: "single XML config for an external tool", - matches: (files) => - files.length === 1 && /\.xml$/i.test(files[0]!) && /(^|\/)(nesa|\d+-l\d+)/i.test(files[0]!), - }, - ], -}; diff --git a/packages/game-extension-test/package.json b/packages/game-extension-test/package.json deleted file mode 100644 index 187a7033e7..0000000000 --- a/packages/game-extension-test/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "@vortex/game-extension-test", - "version": "0.1.0", - "private": true, - "type": "commonjs", - "scripts": { - "start": "pnpm tsx src/cli.ts", - "build": "pnpm tsc", - "typecheck": "pnpm tsc", - "test": "pnpm vitest run" - }, - "devDependencies": { - "@nexusmods/nexus-api": "catalog:", - "@nexusmods/vortex-api": "workspace:*", - "@types/node": "catalog:", - "@vortex/extension-test-mocks": "workspace:*", - "limiter": "catalog:", - "minimist": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:" - }, - "nx": { - "tags": [ - "vortex:tooling" - ], - "targets": { - "start": { - "cache": false, - "continuous": true, - "dependsOn": [ - "build" - ] - } - } - } -} diff --git a/packages/game-extension-test/src/cli.ts b/packages/game-extension-test/src/cli.ts deleted file mode 100644 index c2e7681918..0000000000 --- a/packages/game-extension-test/src/cli.ts +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from "node:child_process"; -import * as path from "node:path"; - -import minimist from "minimist"; - -// pnpm forwards a literal "--" separator into argv; minimist treats "--" as -// stop-parsing, so strip it before parsing flags. -const argv = minimist(process.argv.slice(2).filter((a) => a !== "--")); -const all = argv.all === true || argv.all === "true"; -const single = typeof argv.game === "string" ? argv.game : undefined; -const list = typeof argv.games === "string" ? argv.games.split(",") : undefined; - -if (!all && !single && !list) { - console.error("Usage: --all | --game | --games "); - process.exit(1); -} - -const apiKey = process.env.NEXUS_API_KEY ?? ""; -if (!apiKey) { - console.error("NEXUS_API_KEY environment variable is required."); - process.exit(1); -} - -const packageRoot = path.resolve(__dirname, ".."); -const repoRoot = path.resolve(__dirname, "../../.."); -const games = all ? "all" : (single ?? list?.join(",")); - -const env = { - ...process.env, - GAME_EXT_TEST_REPO: repoRoot, - GAME_EXT_TEST_GAMES: games, -}; - -const vitestConfig = path.join(packageRoot, "vitest.fixtures.config.ts"); -const result = spawnSync("pnpm", ["exec", "vitest", "run", "--config", vitestConfig], { - stdio: "inherit", - env, - cwd: packageRoot, -}); -process.exit(result.status ?? 1); diff --git a/packages/game-extension-test/src/discovery.ts b/packages/game-extension-test/src/discovery.ts deleted file mode 100644 index 401561c924..0000000000 --- a/packages/game-extension-test/src/discovery.ts +++ /dev/null @@ -1,40 +0,0 @@ -import * as fs from "node:fs"; -import * as path from "node:path"; - -/** - * An opted-in extension on disk. - */ -export interface IDiscoveredExtension { - packageJsonPath: string; - packageDir: string; - packageName: string; -} - -/** - * Walk `extensions/games/*` and return the ones whose package.json declares - * `vortex.gameExtensionTest === true`. - */ -export function discoverExtensions(repoRoot: string): IDiscoveredExtension[] { - const gamesDir = path.join(repoRoot, "extensions", "games"); - if (!fs.existsSync(gamesDir)) return []; - const out: IDiscoveredExtension[] = []; - for (const entry of fs.readdirSync(gamesDir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const pkgPath = path.join(gamesDir, entry.name, "package.json"); - if (!fs.existsSync(pkgPath)) continue; - let pkg: any; - try { - pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); - } catch { - continue; - } - if (pkg?.vortex?.gameExtensionTest === true) { - out.push({ - packageJsonPath: pkgPath, - packageDir: path.dirname(pkgPath), - packageName: pkg.name, - }); - } - } - return out; -} diff --git a/packages/game-extension-test/src/index.ts b/packages/game-extension-test/src/index.ts deleted file mode 100644 index eea524d655..0000000000 --- a/packages/game-extension-test/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./types"; diff --git a/packages/game-extension-test/src/loadExtension.ts b/packages/game-extension-test/src/loadExtension.ts deleted file mode 100644 index 50d6cf03d7..0000000000 --- a/packages/game-extension-test/src/loadExtension.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { existsSync } from "node:fs"; -import * as path from "node:path"; - -import type { IGameExtensionTestDescriptor, IModCheckContext } from "./types"; - -/** - * Subset of Vortex's `TestSupported` signature relevant to the harness — we - * only ever call it with `(files, gameId)`. The renderer's full type accepts - * an optional `details` arg that no test driver passes. - */ -export type HarnessTestSupported = ( - files: string[], - gameId: string, -) => Promise<{ supported: boolean; requiredFiles: string[] }>; - -/** - * Subset of Vortex's `InstallFunc` signature relevant to the harness. The - * production type takes 8 positional args (destination, gameId, progress, etc.) - * — we forward them all but only consume `{ instructions }` from the result. - */ -export type HarnessInstall = ( - files: string[], - destinationPath: string, - gameId: string, - progressDelegate: (perc: number) => void, - choices: unknown, - unattended: boolean, - archivePath: string | undefined, - archiveOptions: Record, -) => Promise<{ instructions: Array<{ type: string; [key: string]: unknown }> }>; - -export interface IInstallerEntry { - id: string; - priority: number; - testSupported: HarnessTestSupported; - install: HarnessInstall; -} - -/** - * Per-mod healthcheck shape consumed by `runFixture`. Structural — kept local - * so the harness doesn't need a runtime dep on `vortex-api`. Mirrors - * `IModHealthCheck` from `src/renderer/src/types/IHealthCheck.ts`. - */ -export interface IHarnessModHealthCheck { - id: string; - checkMod: ( - api: unknown, - modCtx: IModCheckContext, - ) => Promise<{ - status: "passed" | "failed" | "warning" | "error"; - severity: string; - message: string; - details?: string; - }>; -} - -/** Minimal shape of the `IGame` object the extension passes to `registerGame`. */ -export interface IHarnessGame { - id: string; - name?: string; - details?: { stopPatterns?: readonly string[]; [key: string]: unknown }; - [key: string]: unknown; -} - -/** - * Result of loading an extension. `installers` is sorted by ascending priority - * to mirror Vortex's `InstallManager.getInstaller` dispatch order (lower - * priority number wins). - */ -export interface ILoadedExtension { - installers: IInstallerEntry[]; - testDescriptor: IGameExtensionTestDescriptor; - /** Each IModHealthCheck the extension exports from src/diagnostic.ts. */ - healthChecks: IHarnessModHealthCheck[]; - gameId: string; - game: IHarnessGame; -} - -/** - * Subset of the renderer's IExtensionContext the harness honors. Everything - * else is captured by a Proxy as a no-op so an extension calling, say, - * `context.registerReducer(...)` during init() doesn't crash the loader. - */ -interface IStubContext { - _installers: IInstallerEntry[]; - _game: IHarnessGame | undefined; - _healthChecks: IHarnessModHealthCheck[]; - registerGame: (game: IHarnessGame) => void; - registerInstaller: ( - id: string, - priority: number, - testSupported: HarnessTestSupported, - install: HarnessInstall, - ) => void; - registerHealthCheck: (check: IHarnessModHealthCheck) => void; - once: (cb: () => void) => void; - api: Record; - [hook: string]: unknown; -} - -export async function loadExtension(extensionDir: string): Promise { - const stubContext = makeStubContext(); - - // GDL extensions (those with a game.yaml) have no hand-written src/index.ts; - // their entry is the bundle produced by `gdl build` (dist/index.js), which - // registers the game, installers, and health checks against the context. - // Legacy hand-written extensions load src/index.ts directly. - const isGdl = existsSync(path.join(extensionDir, "game.yaml")); - // For GDL extensions, load the generated TS entry (not the webpack bundle): - // it runs through the harness's module aliases (@nexusmods/vortex-api -> mock, - // @gdl/runtime -> submodule runtime), whereas the bundled CJS would bypass - // them and fail to resolve the types-only @nexusmods/vortex-api package. - const indexPath = isGdl - ? path.join(extensionDir, ".gdl-out", "extension.ts") - : path.join(extensionDir, "src", "index.ts"); - - if (isGdl && !existsSync(indexPath)) { - throw new Error( - `GDL extension ${extensionDir} has a game.yaml but no generated entry at ` + - `.gdl-out/extension.ts. Run \`gdl build\` (the extension's build script) first.`, - ); - } - - const mod = await import(indexPath); - // Resolve the init function across module shapes: ESM default, CommonJS - // module.exports = fn, and webpack commonjs2's nested { default: fn }. - const candidates = [mod.default, mod.init, mod, (mod.default as { default?: unknown })?.default]; - const init = candidates.find((c) => typeof c === "function") as - | ((ctx: unknown) => void) - | undefined; - if (typeof init !== "function") { - throw new Error(`Extension ${extensionDir} has no default export`); - } - init(stubContext); - - if (stubContext._installers.length === 0) { - throw new Error(`Extension ${extensionDir} did not call registerInstaller`); - } - if (!stubContext._game) { - throw new Error(`Extension ${extensionDir} did not call registerGame`); - } - - const descriptorMod = (await import(path.join(extensionDir, "src", "test-descriptor.ts"))) as { - testDescriptor?: IGameExtensionTestDescriptor; - }; - - if (!descriptorMod.testDescriptor) { - throw new Error( - `Extension ${extensionDir} does not export a testDescriptor ` + - `(expected at src/test-descriptor.ts: export const testDescriptor = ...). ` + - `The harness can't drive fixtures without one.`, - ); - } - - // GDL extensions register their health checks at init() time; legacy - // extensions export them from src/diagnostic.ts. - let healthChecks: IHarnessModHealthCheck[]; - if (isGdl) { - healthChecks = stubContext._healthChecks; - } else { - // Tolerate a missing diagnostic.ts (extension hasn't added one yet), but - // surface any other error so syntax mistakes don't silently become - // "no health checks registered." - const diagnosticPath = path.join(extensionDir, "src", "diagnostic.ts"); - let diagnosticMod: { healthChecks?: unknown } = {}; - try { - diagnosticMod = (await import(diagnosticPath)) as { healthChecks?: unknown }; - } catch (err: unknown) { - const code = (err as { code?: string } | null)?.code; - if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND") { - throw err; - } - } - healthChecks = resolveHealthChecks(diagnosticMod); - } - - if (healthChecks.length === 0) { - throw new Error( - `Extension ${extensionDir} exports testDescriptor but has no health checks ` + - `(GDL: none registered via registerHealthCheck; legacy: none exported from ` + - `src/diagnostic.ts). Without a healthcheck the harness would silently pass ` + - `every fixture.`, - ); - } - - const installers: IInstallerEntry[] = [...stubContext._installers].sort( - (a, b) => a.priority - b.priority, - ); - - return { - installers, - testDescriptor: descriptorMod.testDescriptor, - healthChecks, - gameId: stubContext._game.id, - game: stubContext._game, - }; -} - -function resolveHealthChecks(diagnosticMod: { healthChecks?: unknown }): IHarnessModHealthCheck[] { - if (Array.isArray(diagnosticMod.healthChecks)) { - return diagnosticMod.healthChecks as IHarnessModHealthCheck[]; - } - return []; -} - -function makeStubContext(): IStubContext { - const base: IStubContext = { - _installers: [], - _game: undefined, - _healthChecks: [], - registerGame(game) { - base._game = game; - }, - registerInstaller(id, priority, testSupported, install) { - base._installers.push({ id, priority, testSupported, install }); - }, - registerHealthCheck(check) { - // GDL extensions register health checks at init() time (rather than - // exporting them from diagnostic.ts); capture them here. - base._healthChecks.push(check); - }, - once(_cb) { - /* deferred init not exercised in tests */ - }, - api: {}, - }; - return new Proxy(base, { - get(target, prop, receiver) { - const known = Reflect.get(target, prop, receiver); - if (known !== undefined) return known; - if (typeof prop === "string" && prop.startsWith("register")) { - return () => { - /* unknown register hook — silently accepted */ - }; - } - return undefined; - }, - }); -} diff --git a/packages/game-extension-test/src/manifest.ts b/packages/game-extension-test/src/manifest.ts deleted file mode 100644 index eb07188896..0000000000 --- a/packages/game-extension-test/src/manifest.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { isRetryableStatus, withRetry } from "./retry"; - -interface IPreviewNode { - path?: string; - name?: string; - type?: "directory" | "file"; - size?: string; - children?: IPreviewNode[]; -} - -/** - * CDN fetches are unmetered, so retry faster than the SDK-routed calls in - * `nexusClient.ts`. - */ -export const CDN_RETRY = { maxAttempts: 4, baseDelayMs: 500, maxDelayMs: 10_000 } as const; - -function collectFiles(node: IPreviewNode, out: string[]): void { - if (node.type === "file" && typeof node.path === "string") { - out.push(node.path); - return; - } - if (node.children) { - for (const child of node.children) collectFiles(child, out); - } -} - -/** - * Marker for HTTP responses that came back with a non-retryable status (404 in - * practice). Callers can detect this to differentiate "manifest not on CDN" - * from network failure. - */ -export class FileManifestHttpError extends Error { - constructor( - message: string, - readonly status: number, - ) { - super(message); - this.name = "FileManifestHttpError"; - } -} - -/** - * Fetch the archive content-preview JSON from a public Nexus CDN URL and - * flatten the tree into a list of archive-internal file paths. Retries on - * transient HTTP / network failures; throws `FileManifestHttpError` on - * non-retryable HTTP statuses (typically 404 = preview not on CDN). - */ -export async function fetchFileManifest( - contentPreviewLink: string, - opts: { maxAttempts?: number } = {}, -): Promise { - if (!contentPreviewLink) { - throw new Error("fetchFileManifest: empty content_preview_link"); - } - const url = encodeURI(contentPreviewLink); - - const resp = await withRetry( - async () => { - const r = await fetch(url); - if (!r.ok && isRetryableStatus(r.status)) { - const err = new Error(`HTTP ${r.status}`) as Error & { status: number }; - err.status = r.status; - throw err; - } - return r; - }, - { ...CDN_RETRY, maxAttempts: opts.maxAttempts ?? CDN_RETRY.maxAttempts }, - ); - - if (!resp.ok) { - throw new FileManifestHttpError( - `fetchFileManifest: ${url} returned HTTP ${resp.status}`, - resp.status, - ); - } - - const tree = (await resp.json()) as IPreviewNode; - const out: string[] = []; - collectFiles(tree, out); - return out; -} diff --git a/packages/game-extension-test/src/materializeInstall.ts b/packages/game-extension-test/src/materializeInstall.ts deleted file mode 100644 index a4eb06730b..0000000000 --- a/packages/game-extension-test/src/materializeInstall.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { IModCheckContext } from "./types"; -import { lastSegment } from "./util"; - -/** - * Convert an installer's IInstruction[] output into the IModCheckContext that - * a per-mod healthcheck consumes. - * - * Copy instructions become the file list (with `destination` as path under the - * mod root). Attribute instructions become the attributes map. - */ -export function materializeInstall( - modId: string, - instructions: Array<{ - type: string; - destination?: string; - key?: string; - value?: unknown; - }>, - readFileForBasename: (basename: string) => Promise, -): IModCheckContext { - const files: string[] = []; - const attributes: Record = {}; - for (const inst of instructions) { - if (inst.type === "copy" && inst.destination) { - files.push(inst.destination); - } else if (inst.type === "attribute" && inst.key !== undefined) { - attributes[inst.key] = inst.value; - } else if (inst.type === "setmodtype") { - attributes.modType = inst.value; - } - // generatefile, mkdir, etc. are ignored for now — extend as needed. - } - return { - modId, - files, - readFile: (rel) => readFileForBasename(lastSegment(rel)), - attributes, - }; -} diff --git a/packages/game-extension-test/src/mockApi.ts b/packages/game-extension-test/src/mockApi.ts deleted file mode 100644 index 661d9c41d5..0000000000 --- a/packages/game-extension-test/src/mockApi.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { setReadFileResolver } from "@vortex/extension-test-mocks"; -import { vi } from "vitest"; - -import type { IGameExtensionTestDescriptor, ISyntheticContext } from "./types"; -import { lastSegment } from "./util"; - -export interface IMockApi { - api: unknown; // IExtensionApi shape; consumed via duck-typing inside extensions - readFileCalls: { path: string }[]; -} - -/** - * Build the mocked IExtensionApi handed to the per-mod healthcheck. Also - * primes the shared vortex-api mock's readFile resolver so that - * `fs.readFileAsync` inside the installer reads from synthetic content. - */ -export function buildMockApi( - descriptor: IGameExtensionTestDescriptor, - manifest: string[], - ctx: ISyntheticContext, -): IMockApi { - const calls: { path: string }[] = []; - - setReadFileResolver(async (absPath: string) => { - calls.push({ path: absPath }); - const baseName = lastSegment(absPath); - const generator = descriptor.syntheticContent[baseName]; - if (!generator) return Buffer.alloc(0); - const out = generator(ctx); - return typeof out === "string" ? Buffer.from(out, "utf8") : out; - }); - - const api = { - getState: () => ({ - persistent: { mods: {} }, - settings: { mods: { installPath: {} } }, - session: { base: { activeGameId: descriptor.gameId } }, - }), - store: { getState: () => ({}), dispatch: vi.fn() }, - onStateChange: vi.fn(), - showErrorNotification: vi.fn(), - log: vi.fn(), - ext: {}, - }; - - return { api, readFileCalls: calls }; -} diff --git a/packages/game-extension-test/src/nexusClient.ts b/packages/game-extension-test/src/nexusClient.ts deleted file mode 100644 index 97547e8106..0000000000 --- a/packages/game-extension-test/src/nexusClient.ts +++ /dev/null @@ -1,260 +0,0 @@ -import Nexus from "@nexusmods/nexus-api"; -import { RateLimiter } from "limiter"; - -import { fetchFileManifest } from "./manifest"; -import { withRetry } from "./retry"; - -export interface INexusClient { - listMostPopular(gameDomain: string, limit: number): Promise; - listMostRecent(gameDomain: string, limit: number): Promise; - listOldest(gameDomain: string, limit: number): Promise; - /** - * Enumerate every mod for the game via paginated GraphQL. Caller is - * responsible for the per-mod `listModFiles` follow-up. - */ - listAllMods(gameDomain: string): Promise; - listCollections(gameDomain: string): Promise; - listCollectionMods(gameDomain: string, collectionSlug: string): Promise; - listModFiles(gameDomain: string, modId: number): Promise; - /** - * Fetch the content-preview JSON for a file and flatten it into the list of - * file paths inside the archive. Throws if the URL is empty or the fetch - * fails. - */ - getFileManifest(contentPreviewLink: string): Promise; -} - -export interface INexusModSummary { - modId: number; - name: string; -} - -export interface INexusCollectionSummary { - slug: string; - name: string; -} - -export interface INexusFileSummary { - fileId: number; - /** Display name (human-readable, no extension). */ - name: string; - /** Actual filename with extension. */ - fileName: string; - /** Nexus category: "MAIN", "PATCH", "OPTIONAL", "OLD_VERSION", "MISCELLANEOUS", "DELETED", "ARCHIVED". */ - categoryName: string; - uploadedAt: Date; - /** URL of the archive content-preview JSON; empty string if not provided. */ - contentPreviewLink: string; -} - -/** api.nexusmods.com is rate-limited; longer backoff than the unmetered CDN fetches in `manifest.ts`. */ -const SDK_RETRY = { maxAttempts: 4, baseDelayMs: 1000, maxDelayMs: 30_000 } as const; - -/** ~25 requests per second matches the published Nexus API quota with a small safety margin. */ -const RATE_LIMIT_PER_SEC = 25; - -/** GraphQL page size for `listAllMods`. */ -const GRAPHQL_PAGE_SIZE = 100; - -/** Default page size for collection-listing GraphQL calls. */ -const COLLECTION_LIST_PAGE_SIZE = 100; - -/** - * The Nexus SDK's type surface in v1.6.0 is incomplete: `getModFiles` results - * include `file_name` / `category_name` that aren't declared, and the - * collection GraphQL methods aren't on the typed interface at all. We cast - * once at the boundary so consumer code can stay strictly-typed. - */ -interface IRawNexusFile { - file_id: number; - name: string; - uploaded_timestamp: number; - file_name?: string; - category_name?: string; - content_preview_link?: string; -} - -interface IRawNexusCollectionListItem { - slug?: string; - name?: string; -} - -interface IRawNexusCollectionDetail { - currentRevision?: { modFiles?: Array<{ file?: { modId?: number; name?: string } }> }; -} - -interface INexusGraphQL { - getCollectionListGraph( - query: object, - gameDomain: string, - count: number, - offset: number, - ): Promise; - getCollectionGraph( - query: object, - slug: string, - adult: boolean, - ): Promise; -} - -interface IListAllModsResponse { - data?: { - mods?: { totalCount?: number; nodes?: Array<{ modId: number; name: string }> }; - }; - errors?: Array<{ message: string }>; -} - -export function createNexusClient(apiKey: string): INexusClient { - const limiter = new RateLimiter({ tokensPerInterval: RATE_LIMIT_PER_SEC, interval: "second" }); - - let nexusPromise: Promise | undefined; - - function getNexus(): Promise { - if (!nexusPromise) { - nexusPromise = Nexus.create(apiKey, "vortex-game-extension-test", "1.0.0", "site"); - } - return nexusPromise; - } - - async function call(fn: (nexus: Nexus) => Promise): Promise { - await limiter.removeTokens(1); - const nexus = await getNexus(); - return withRetry(() => fn(nexus), SDK_RETRY); - } - - return { - // getTrending sorts by endorsements/popularity. - async listMostPopular(gameDomain: string, limit: number): Promise { - const results = await call((nexus) => nexus.getTrending(gameDomain)); - return results.slice(0, limit).map((m) => ({ modId: m.mod_id, name: m.name ?? "" })); - }, - - async listMostRecent(gameDomain: string, limit: number): Promise { - const results = await call((nexus) => nexus.getLatestAdded(gameDomain)); - return results.slice(0, limit).map((m) => ({ modId: m.mod_id, name: m.name ?? "" })); - }, - - // The REST API has no "oldest" sort; getLatestUpdated returns ascending by - // update time, so reversing puts the least-recently-updated mods first. - // This is the closest approximation available in @nexusmods/nexus-api v1.6.0. - async listOldest(gameDomain: string, limit: number): Promise { - const results = await call((nexus) => nexus.getLatestUpdated(gameDomain)); - return results - .slice() - .reverse() - .slice(0, limit) - .map((m) => ({ modId: m.mod_id, name: m.name ?? "" })); - }, - - // The SDK doesn't expose raw GraphQL on the typed surface, so hit - // /v2/graphql directly. Wrapped in withRetry to match call()'s protection. - async listAllMods(gameDomain: string): Promise { - const out: INexusModSummary[] = []; - let offset = 0; - while (true) { - const data = await withRetry(async () => { - await limiter.removeTokens(1); - const resp = await fetch("https://api.nexusmods.com/v2/graphql", { - method: "POST", - headers: { - "Content-Type": "application/json", - APIKEY: apiKey, - }, - body: JSON.stringify({ - query: - "query($domain: String!, $count: Int!, $offset: Int!) {" + - " mods(filter: { filter: [{ gameDomainName: { value: $domain, op: EQUALS } }] }, count: $count, offset: $offset) {" + - " totalCount nodes { modId name }" + - " } }", - variables: { domain: gameDomain, count: GRAPHQL_PAGE_SIZE, offset }, - }), - }); - if (!resp.ok) { - // Tag with `status` so withRetry only retries 408/429/5xx. - const err = Object.assign(new Error(`listAllMods: HTTP ${resp.status}`), { - status: resp.status, - }); - throw err; - } - const json = (await resp.json()) as IListAllModsResponse; - if (json.errors?.length) { - // GraphQL semantic errors arrive over 200; tag non-retryable so - // withRetry fails fast. - const err = Object.assign( - new Error(`listAllMods GraphQL: ${json.errors.map((e) => e.message).join("; ")}`), - { status: 400 }, - ); - throw err; - } - return json; - }, SDK_RETRY); - - const page = data.data?.mods?.nodes ?? []; - for (const m of page) out.push({ modId: m.modId, name: m.name ?? "" }); - const total = data.data?.mods?.totalCount ?? 0; - offset += page.length; - if (offset >= total || page.length === 0) break; - } - return out; - }, - - async listCollections(gameDomain: string): Promise { - const query = { slug: true, name: true }; - const results = await call((nexus) => - (nexus as unknown as INexusGraphQL).getCollectionListGraph( - query, - gameDomain, - COLLECTION_LIST_PAGE_SIZE, - 0, - ), - ); - return results.map((c) => ({ - slug: c.slug ?? "", - name: c.name ?? "", - })); - }, - - async listCollectionMods( - gameDomain: string, - collectionSlug: string, - ): Promise { - const query = { - slug: true, - name: true, - currentRevision: { - modFiles: { - file: { - modId: true, - name: true, - }, - }, - }, - }; - const collection = await call((nexus) => - (nexus as unknown as INexusGraphQL).getCollectionGraph(query, collectionSlug, false), - ); - - const modFiles = collection.currentRevision?.modFiles ?? []; - return modFiles - .filter((mf) => mf.file?.modId != null) - .map((mf) => ({ - modId: mf.file!.modId as number, - name: mf.file!.name ?? "", - })); - }, - - async listModFiles(gameDomain: string, modId: number): Promise { - const result = await call((nexus) => nexus.getModFiles(modId, gameDomain)); - return (result.files as unknown as IRawNexusFile[]).map((f) => ({ - fileId: f.file_id, - name: f.name, - fileName: f.file_name ?? "", - categoryName: f.category_name ?? "", - uploadedAt: new Date(f.uploaded_timestamp * 1000), - contentPreviewLink: f.content_preview_link ?? "", - })); - }, - - getFileManifest: (contentPreviewLink: string) => fetchFileManifest(contentPreviewLink), - }; -} diff --git a/packages/game-extension-test/src/resolveFixtures.ts b/packages/game-extension-test/src/resolveFixtures.ts deleted file mode 100644 index 5b571a6e5b..0000000000 --- a/packages/game-extension-test/src/resolveFixtures.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { INexusClient } from "./nexusClient"; -import type { IFixture, IGameExtensionTestDescriptor } from "./types"; -import { getErrorStatus } from "./util"; - -/** Mod-level row before per-file expansion. */ -interface IModRef { - origin: IFixture["origin"]; - modId: number; -} - -/** File-level categories that we skip (Nexus's `category_name` values). */ -const EXCLUDED_CATEGORIES = new Set(["DELETED", "ARCHIVED"]); - -/** Filename extensions for which Nexus generates a content-preview JSON. */ -const ARCHIVE_EXTENSIONS = [".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz"]; - -/** - * For each opted-in source in the descriptor, enumerate every non-deleted, - * non-archived archive file for every selected mod. Returns one `IFixture` per - * file (so a mod with five archive uploads becomes five fixtures). - * - * Parallelises the per-mod `listModFiles` calls via the client's built-in - * rate-limiter (~25 req/s). - */ -export async function resolveFixtures( - client: INexusClient, - descriptor: IGameExtensionTestDescriptor, -): Promise { - const seenMod = new Set(); - const modRefs: IModRef[] = []; - const tryAddMod = (r: IModRef) => { - if (seenMod.has(r.modId)) return; - seenMod.add(r.modId); - modRefs.push(r); - }; - - const d = descriptor.nexusGameDomain; - if (descriptor.fixtures.all) { - for (const m of await client.listAllMods(d)) { - tryAddMod({ origin: "all", modId: m.modId }); - } - } - if (descriptor.fixtures.mostPopular > 0) { - for (const m of await client.listMostPopular(d, descriptor.fixtures.mostPopular)) { - tryAddMod({ origin: "mostPopular", modId: m.modId }); - } - } - if (descriptor.fixtures.mostRecent > 0) { - for (const m of await client.listMostRecent(d, descriptor.fixtures.mostRecent)) { - tryAddMod({ origin: "mostRecent", modId: m.modId }); - } - } - if (descriptor.fixtures.oldest > 0) { - for (const m of await client.listOldest(d, descriptor.fixtures.oldest)) { - tryAddMod({ origin: "oldest", modId: m.modId }); - } - } - if (descriptor.fixtures.allCollections) { - let cols: Awaited>; - try { - cols = await client.listCollections(d); - } catch (err: unknown) { - console.warn( - `resolveFixtures: listCollections failed for ${d}; skipping collection fixtures. ` + - (err instanceof Error ? err.message : String(err)), - ); - cols = []; - } - for (const c of cols) { - try { - for (const m of await client.listCollectionMods(d, c.slug)) { - tryAddMod({ origin: { type: "collection", collectionId: c.slug }, modId: m.modId }); - } - } catch (err: unknown) { - console.warn( - `resolveFixtures: collection ${c.slug} failed; skipping. ` + - (err instanceof Error ? err.message : String(err)), - ); - } - } - } - - // Fan out: one listModFiles per mod, throttled by the client's rate-limiter. - // Individual 403/404s (deleted/hidden mods) are swallowed; everything else - // propagates and aborts the run. - const perModFiles = await Promise.all( - modRefs.map(async (ref) => { - try { - const files = await client.listModFiles(d, ref.modId); - return { ref, files }; - } catch (err: unknown) { - const status = getErrorStatus(err); - if (status === 403 || status === 404) { - return { ref, files: [] }; - } - throw err; - } - }), - ); - - const out: IFixture[] = []; - const seenFile = new Set(); - for (const { ref, files } of perModFiles) { - for (const f of files) { - if (EXCLUDED_CATEGORIES.has(f.categoryName)) continue; - if (!isArchiveFile(f.fileName)) continue; - if (seenFile.has(f.fileId)) continue; - seenFile.add(f.fileId); - out.push({ - origin: ref.origin, - modId: ref.modId, - fileId: f.fileId, - fileName: f.fileName, - contentPreviewLink: f.contentPreviewLink, - }); - } - } - return out; -} - -function isArchiveFile(name: string): boolean { - const lower = name.toLowerCase(); - return ARCHIVE_EXTENSIONS.some((ext) => lower.endsWith(ext)); -} diff --git a/packages/game-extension-test/src/retry.ts b/packages/game-extension-test/src/retry.ts deleted file mode 100644 index 44cc1172a3..0000000000 --- a/packages/game-extension-test/src/retry.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { getErrorStatus } from "./util"; - -/** - * Shared retry primitive used by both the Nexus SDK wrapper and direct CDN - * fetches. Retries network-level failures and 408/429/5xx; bails on everything - * else. Callers wrapping `fetch` must throw a marker error themselves on - * retryable HTTP statuses since `fetch` doesn't throw on non-2xx. - */ -export interface IRetryOptions { - maxAttempts?: number; - baseDelayMs?: number; - maxDelayMs?: number; -} - -export function isRetryableStatus(status: number): boolean { - return status === 408 || status === 429 || (status >= 500 && status < 600); -} - -export const sleep = (ms: number): Promise => - new Promise((resolve) => setTimeout(resolve, ms)); - -export async function withRetry(fn: () => Promise, opts: IRetryOptions = {}): Promise { - const { maxAttempts = 4, baseDelayMs = 500, maxDelayMs = 10_000 } = opts; - let lastErr: unknown; - for (let attempt = 0; attempt < maxAttempts; attempt++) { - try { - return await fn(); - } catch (err: unknown) { - lastErr = err; - const status = getErrorStatus(err); - const transient = status === undefined || isRetryableStatus(status); - if (!transient || attempt === maxAttempts - 1) { - throw err; - } - - const base = baseDelayMs * 2 ** attempt; - const jitter = Math.random() * base * 0.5; - await sleep(Math.min(base + jitter, maxDelayMs)); - } - } - throw lastErr; -} diff --git a/packages/game-extension-test/src/runFixture.ts b/packages/game-extension-test/src/runFixture.ts deleted file mode 100644 index ab5e1a5d15..0000000000 --- a/packages/game-extension-test/src/runFixture.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { ILoadedExtension } from "./loadExtension"; -import { materializeInstall } from "./materializeInstall"; -import { buildMockApi } from "./mockApi"; -import type { FixtureOutcome, IFixture, IGameExtensionTestDescriptor } from "./types"; - -const VIRTUAL_DEST = "/virtual-dest"; - -export async function runFixture( - ext: ILoadedExtension, - fixture: IFixture, - manifest: string[], -): Promise { - const descriptor: IGameExtensionTestDescriptor = ext.testDescriptor; - const ctx = { - manifestId: `${fixture.modId}-${fixture.fileId}`, - modId: fixture.modId, - fileId: fixture.fileId, - }; - const { api } = buildMockApi(descriptor, manifest, ctx); - - // Walk installers in ascending priority order (mirrors Vortex's - // InstallManager.getInstaller dispatch). First one returning supported=true wins. - let chosen: (typeof ext.installers)[number] | undefined; - for (const inst of ext.installers) { - let supported: { supported: boolean; requiredFiles: string[] }; - try { - supported = await inst.testSupported(manifest, ext.gameId); - } catch (err: unknown) { - return { - kind: "failed", - issues: [`testSupported (${inst.id}) threw: ${errorMessage(err)}`], - }; - } - if (supported.supported) { - chosen = inst; - break; - } - } - if (!chosen) { - return { kind: "rejected", reason: "no installer accepted the file" }; - } - - let result: { instructions: Array<{ type: string; [key: string]: unknown }> }; - try { - result = await chosen.install( - manifest, - VIRTUAL_DEST, - ext.gameId, - () => { - /* noop progress */ - }, - undefined, - true, - undefined, - {}, - ); - } catch (err: unknown) { - return { kind: "failed", issues: [`install (${chosen.id}) threw: ${errorMessage(err)}`] }; - } - - const modCtx = materializeInstall(ctx.manifestId, result.instructions, async (basename) => { - const generator = descriptor.syntheticContent[basename]; - if (!generator) return Buffer.alloc(0); - const out = generator(ctx); - return typeof out === "string" ? Buffer.from(out, "utf8") : out; - }); - - const issues: string[] = []; - const messages: string[] = []; - for (const hc of ext.healthChecks) { - const checkResult = await hc.checkMod(api, modCtx); - if (checkResult.status === "failed" || checkResult.status === "error") { - issues.push(`${hc.id} (${checkResult.severity}): ${checkResult.message}`); - } else { - messages.push(`${hc.id}: ${checkResult.message}`); - } - } - if (issues.length > 0) { - return { kind: "failed", issues }; - } - return { kind: "passed", modCheckMessage: messages.join("; ") }; -} - -function errorMessage(err: unknown): string { - if (err instanceof Error) return err.message; - return String(err); -} diff --git a/packages/game-extension-test/src/runOneFixture.ts b/packages/game-extension-test/src/runOneFixture.ts deleted file mode 100644 index 8be3016ac2..0000000000 --- a/packages/game-extension-test/src/runOneFixture.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { loadExtension } from "./loadExtension"; -import { FileManifestHttpError, fetchFileManifest } from "./manifest"; -import { runFixture } from "./runFixture"; -import type { IFixture } from "./types"; - -/** - * Run a single pre-resolved fixture: fetch the manifest, drive the installer, - * run the diagnostic. - * - * Returns a skip reason string when something genuinely couldn't be tested - * (manifest missing on the CDN). Throws on real failures, including the - * installer rejecting the file — rejection means "no installer handler - * registered for this file type" and is a signal we need to address, not hide. - */ -export async function runOneFixture(args: { - extensionDir: string; - fixture: IFixture; -}): Promise { - if (!args.fixture.contentPreviewLink) { - return `no content_preview_link for fileId=${args.fixture.fileId}`; - } - - let manifest: string[]; - try { - manifest = await fetchFileManifest(args.fixture.contentPreviewLink); - } catch (err: unknown) { - if (err instanceof FileManifestHttpError && err.status === 404) { - return `manifest not on CDN: ${err.message}`; - } - throw err; - } - - const ext = await loadExtension(args.extensionDir); - - const skipHeuristics = ext.testDescriptor.skipHeuristics ?? []; - for (const h of skipHeuristics) { - if (h.matches(manifest)) { - return `skipped by heuristic: ${h.reason}`; - } - } - - const outcome = await runFixture(ext, args.fixture, manifest); - if (outcome.kind === "failed") { - throw new Error(outcome.issues.join("; ")); - } - if (outcome.kind === "rejected") { - throw new Error( - `[${ext.gameId}] installer rejected file ${args.fixture.fileName} (modId=${args.fixture.modId}, fileId=${args.fixture.fileId}). ` + - `If this is intentional, add a more specific installer that supports this file shape; ` + - `otherwise an existing installer's testSupported needs to accept it.`, - ); - } - return undefined; -} diff --git a/packages/game-extension-test/src/test-entry.test.ts b/packages/game-extension-test/src/test-entry.test.ts deleted file mode 100644 index fac0bd7f84..0000000000 --- a/packages/game-extension-test/src/test-entry.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import * as path from "node:path"; - -import { test } from "vitest"; - -import { discoverExtensions } from "./discovery"; -import { createNexusClient } from "./nexusClient"; -import { resolveFixtures } from "./resolveFixtures"; -import { runOneFixture } from "./runOneFixture"; - -/** - * Single test file that fans out to one `test.concurrent` per Nexus *file* - * (every non-deleted, non-archived archive across every selected mod). - * - * The CLI sets GAME_EXT_TEST_REPO and GAME_EXT_TEST_GAMES; without those, the - * file degrades to a single no-op test. - */ - -const repoRoot = process.env.GAME_EXT_TEST_REPO; -const games = process.env.GAME_EXT_TEST_GAMES ?? "all"; -const apiKey = process.env.NEXUS_API_KEY ?? ""; - -if (!repoRoot || !apiKey) { - test("environment not configured (skipped)", () => { - // The CLI sets these vars; ad-hoc `vitest run` outside the CLI skips here. - }); -} else { - const requested = games === "all" ? null : games.split(","); - const exts = discoverExtensions(repoRoot); - const selected = - requested === null - ? exts - : exts.filter( - (e) => - requested.includes(e.packageName) || - requested.some((g) => e.packageDir.endsWith(`/${g}`)), - ); - - const client = createNexusClient(apiKey); - for (const found of selected) { - // @ts-ignore TS1378 — top-level await works under vitest's Vite transform. - const descriptor = // @ts-ignore TS1378 - (await import(path.join(found.packageDir, "src", "test-descriptor.ts"))).testDescriptor; - // @ts-ignore TS1378 - const fixtures = await resolveFixtures(client, descriptor); - for (const fx of fixtures) { - test.concurrent(`${descriptor.gameId} > modId=${fx.modId} fileId=${fx.fileId} (${fx.fileName})`, async (ctx) => { - const skipReason = await runOneFixture({ - extensionDir: found.packageDir, - fixture: fx, - }); - if (skipReason) ctx.skip(skipReason); - }); - } - } -} diff --git a/packages/game-extension-test/src/types.ts b/packages/game-extension-test/src/types.ts deleted file mode 100644 index 9a995392c3..0000000000 --- a/packages/game-extension-test/src/types.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Per-game opt-in descriptor exported as `testDescriptor` from each extension. - */ -export interface IGameExtensionTestDescriptor { - /** Internal Vortex game id, matches the value passed to registerGame. */ - gameId: string; - - /** Nexus game-domain slug used when calling the Nexus API. */ - nexusGameDomain: string; - - fixtures: { - mostPopular: number; - mostRecent: number; - oldest: number; - allCollections: boolean; - /** - * When true, fetch *every* mod for the game via paginated GraphQL. - * For small games this is fine; for large games (Skyrim, etc.) it - * generates a lot of API traffic. - */ - all: boolean; - }; - - /** - * Maps a filename (matched by `path.basename`) to a generator that returns - * the bytes/string to return when the installer reads that file. - * - * The generator receives a context object so different fixtures can produce - * different content if needed. - */ - syntheticContent: Record string | Buffer>; - - /** - * Heuristics that classify fixtures as "not worth running through the - * installer chain" (cheat tables, nested archives requiring prior - * extraction, single instruction-text uploads, etc.). - * - * Evaluated after the manifest is fetched, before testSupported runs. If - * any heuristic's `matches(files)` returns true, the fixture is reported as - * skipped with the heuristic's reason and the installer chain is bypassed. - */ - skipHeuristics?: ISkipHeuristic[]; -} - -export interface ISkipHeuristic { - reason: string; - matches: (files: string[]) => boolean; -} - -export interface ISyntheticContext { - /** Stable identifier derived from manifest's file_id (for use as mod id). */ - manifestId: string; - modId: number; - fileId: number; -} - -/** A single fixture row resolved from the Nexus API. */ -export interface IFixture { - origin: - | "all" - | "mostPopular" - | "mostRecent" - | "oldest" - | { type: "collection"; collectionId: string }; - modId: number; - fileId: number; - fileName: string; - /** URL of the file's content-preview JSON (manifest endpoint). */ - contentPreviewLink: string; - /** File-tree manifest (paths relative to archive root). Lazy-fetched. */ - manifest?: string[]; -} - -/** Outcome of running one fixture. */ -export type FixtureOutcome = - | { kind: "passed"; modCheckMessage: string } - | { kind: "rejected"; reason: string } - | { kind: "failed"; issues: string[] } - | { kind: "skipped"; reason: string }; - -/** - * Per-mod context passed to a healthcheck. Mirrors the framework's - * IModCheckContext shape; kept here to avoid a cross-package import. - */ -export interface IModCheckContext { - modId: string; - files: string[]; - readFile: (path: string) => Promise; - attributes: Record; -} diff --git a/packages/game-extension-test/src/util.ts b/packages/game-extension-test/src/util.ts deleted file mode 100644 index c5a1f70e15..0000000000 --- a/packages/game-extension-test/src/util.ts +++ /dev/null @@ -1,12 +0,0 @@ -export function lastSegment(p: string): string { - const idx = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")); - return idx === -1 ? p : p.slice(idx + 1); -} - -export function getErrorStatus(err: unknown): number | undefined { - if (typeof err !== "object" || err === null) return undefined; - const e = err as Record; - if (typeof e.statusCode === "number") return e.statusCode; - if (typeof e.status === "number") return e.status; - return undefined; -} diff --git a/packages/game-extension-test/tsconfig.json b/packages/game-extension-test/tsconfig.json deleted file mode 100644 index 39ecff5e9d..0000000000 --- a/packages/game-extension-test/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "$schema": "https://www.schemastore.org/tsconfig.json", - "extends": "../../tsconfig.strict.json", - "include": ["./src/**/*.ts", "../../typings.custom/promise.d.ts"], - "compilerOptions": { - "composite": true, - "noEmit": true, - "target": "esnext", - "module": "esnext", - "moduleResolution": "bundler", - "customConditions": ["development"], - "lib": ["ESNext"], - "types": ["node"], - "allowJs": false, - "isolatedDeclarations": true, - "noUncheckedSideEffectImports": true - } -} diff --git a/packages/game-extension-test/vitest.config.ts b/packages/game-extension-test/vitest.config.ts deleted file mode 100644 index 9459008aa4..0000000000 --- a/packages/game-extension-test/vitest.config.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createRequire } from "node:module"; - -import { mergeConfig, defineConfig } from "vitest/config"; - -import baseConfig from "../../vitest.base.config"; - -const require_ = createRequire(import.meta.url); -const VORTEX_API_MOCK = require_.resolve("@vortex/extension-test-mocks"); - -export default mergeConfig( - baseConfig, - defineConfig({ - resolve: { - alias: [{ find: /^@nexusmods\/vortex-api$/, replacement: VORTEX_API_MOCK }], - }, - test: { - environment: "node", - include: ["src/**/*.test.ts"], - }, - }), -); diff --git a/packages/game-extension-test/vitest.fixtures.config.ts b/packages/game-extension-test/vitest.fixtures.config.ts deleted file mode 100644 index aff1c3bbb4..0000000000 --- a/packages/game-extension-test/vitest.fixtures.config.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { createRequire } from "node:module"; -import * as path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { defineConfig } from "vitest/config"; - -/** - * Vitest config for the dynamic fixture runner driven by `cli.ts`. Each - * `test.concurrent` invocation handles one Nexus file. - */ -const require_ = createRequire(import.meta.url); -const VORTEX_API_MOCK = require_.resolve("@vortex/extension-test-mocks"); - -// GDL-generated extensions import the runtime via the `@gdl/runtime` webpack -// alias. The harness loads the generated TS (not the bundle), so map the same -// specifier to the submodule's runtime source. -const here = path.dirname(fileURLToPath(import.meta.url)); -const GDL_RUNTIME = path.resolve(here, "../../game-description-language/src/runtime/index.ts"); - -// Each fixture makes at least two HTTP calls (listModFiles + manifest); a slow -// Nexus response can easily exceed vitest's 5s default. -const FIXTURE_TEST_TIMEOUT_MS = 30_000; - -// One authenticated Nexus call per fixture; staying just under the SDK's -// 25-req/s burst keeps headroom for the unmetered CDN manifest fetches. -const FIXTURE_MAX_CONCURRENCY = 24; - -export default defineConfig({ - resolve: { - alias: [ - { find: /^@nexusmods\/vortex-api$/, replacement: VORTEX_API_MOCK }, - { find: /^@gdl\/runtime$/, replacement: GDL_RUNTIME }, - ], - }, - test: { - environment: "node", - include: ["src/test-entry.test.ts"], - testTimeout: FIXTURE_TEST_TIMEOUT_MS, - maxConcurrency: FIXTURE_MAX_CONCURRENCY, - }, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 966f67423d..e1bd2dbbe9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2656,9 +2656,6 @@ importers: '@vortex/extension-test-mocks': specifier: workspace:* version: link:../../../packages/extension-test-mocks - '@vortex/game-extension-test': - specifier: workspace:* - version: link:../../../packages/game-extension-test extensions/gamestore-gog: devDependencies: @@ -4065,33 +4062,6 @@ importers: specifier: 'catalog:' version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(happy-dom@20.9.0)(vite@8.0.16) - packages/game-extension-test: - devDependencies: - '@nexusmods/nexus-api': - specifier: 'catalog:' - version: https://codeload.github.com/Nexus-Mods/node-nexus-api/tar.gz/99a97cd1359527dbf5c46c93723d1846538badfe - '@nexusmods/vortex-api': - specifier: workspace:* - version: link:../vortex-api - '@types/node': - specifier: 'catalog:' - version: 24.12.2 - '@vortex/extension-test-mocks': - specifier: workspace:* - version: link:../extension-test-mocks - limiter: - specifier: 'catalog:' - version: 3.0.0 - minimist: - specifier: 'catalog:' - version: 1.2.8 - typescript: - specifier: 'catalog:' - version: 6.0.3 - vitest: - specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.8)(@vitest/ui@4.1.8)(happy-dom@20.9.0)(vite@8.0.16) - packages/icon-extract: devDependencies: pe-resources: From d62a16b50d76c69a093f58e2c223127dd826d51c Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Wed, 17 Jun 2026 07:22:30 -0700 Subject: [PATCH 05/12] Enable GDL corpus testing for X Rebirth Add `tests: { corpus: nexus }` so `gdl test:corpus --fetch` runs the declarative installers against real X Rebirth mod manifests from Nexus. Gitignore the fetched tests/cache. Note: GDL's corpus runner currently can't execute the content.xml install hook (it builds plans statically), so content.xml mods route to the dropin installer in the corpus rather than content-xml, and the Nexus discovery query is not paginated (samples the first ~20 mods). The corpus is a partial smoke test of declarative routing until those gaps are addressed in GDL. --- .gitignore | 1 + extensions/games/game-xrebirth/game.yaml | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 03bdb63a28..99a7559b38 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ dist_custom dist_portable dist_web .gdl-out +**/tests/cache out doc coverage diff --git a/extensions/games/game-xrebirth/game.yaml b/extensions/games/game-xrebirth/game.yaml index 0f4a1cb044..cc7514e3f2 100644 --- a/extensions/games/game-xrebirth/game.yaml +++ b/extensions/games/game-xrebirth/game.yaml @@ -135,6 +135,11 @@ stopPatterns: - { regex: '[^/]*\.(mkv|mp4|webm)$' } - { regex: '[^/]*\.ini$' } +# Corpus testing: run the declarative installers against real X Rebirth mod +# manifests fetched from Nexus (`gdl test:corpus --fetch`). +tests: + corpus: nexus + # In-game health checks (registered via context.registerHealthCheck). diagnostics: - hook: modHasFilesCheck From b5b7b28526890216955c568119b3632e0d6bed75 Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Wed, 17 Jun 2026 09:05:41 -0700 Subject: [PATCH 06/12] xrebirth: add corpus syntheticContent; bump GDL submodule Bump game-description-language to pick up corpus pagination, hook attribution, and execution mode (hooks + health checks run against the full Nexus catalog). Add tests.syntheticContent for content.xml so the corpus can drive the content.xml install hook. A full `gdl test:corpus --fetch` now covers 302 mods (was 20): 299 matched with correct installer attribution (259 content-xml via the hook), 3 non-mod uploads unmatched, 0 health-check failures. --- extensions/games/game-xrebirth/game.yaml | 9 +++++++-- game-description-language | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/extensions/games/game-xrebirth/game.yaml b/extensions/games/game-xrebirth/game.yaml index cc7514e3f2..548c684b44 100644 --- a/extensions/games/game-xrebirth/game.yaml +++ b/extensions/games/game-xrebirth/game.yaml @@ -135,10 +135,15 @@ stopPatterns: - { regex: '[^/]*\.(mkv|mp4|webm)$' } - { regex: '[^/]*\.ini$' } -# Corpus testing: run the declarative installers against real X Rebirth mod -# manifests fetched from Nexus (`gdl test:corpus --fetch`). +# Corpus testing: run the installers against real X Rebirth mod manifests +# fetched from Nexus (`gdl test:corpus --fetch`). When the extension is built, +# the corpus drives the real installer chain (including the content.xml hook) +# and health checks; syntheticContent supplies the bytes the hook reads, since +# manifests carry only file lists. tests: corpus: nexus + syntheticContent: + content.xml: '' # In-game health checks (registered via context.registerHealthCheck). diagnostics: diff --git a/game-description-language b/game-description-language index e853c207f1..c432726757 160000 --- a/game-description-language +++ b/game-description-language @@ -1 +1 @@ -Subproject commit e853c207f1f5387ed356fcea6646caa5781b5a5f +Subproject commit c432726757a03a3a5efcf892dfb61454e1ef67c1 From 88788eb454e19b59abe7fe058eee638004dbc83f Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Wed, 17 Jun 2026 09:55:18 -0700 Subject: [PATCH 07/12] xrebirth: express stopPatterns as globs Convert the drop-in stopPatterns from regex to globs (GDL converts them to case-insensitive regex at build time), which read more clearly and match the GDL idiom. Drop the redundant lang.dat pattern (subsumed by **/*.dat). Verified behavior-preserving: the full corpus run is unchanged at 299 matched / 3 unmatched / 0 failed (302 mods), same installer attribution, 0 health-check failures. --- extensions/games/game-xrebirth/game.yaml | 39 ++++++++++++------------ 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/extensions/games/game-xrebirth/game.yaml b/extensions/games/game-xrebirth/game.yaml index 548c684b44..5435249a91 100644 --- a/extensions/games/game-xrebirth/game.yaml +++ b/extensions/games/game-xrebirth/game.yaml @@ -114,26 +114,27 @@ installers: copy: { stripCommonRoot: true } modType: xrebirth-documentation -# Drop-in conflict-detection patterns. Authored as regex (compiled -# case-insensitively, matching the original details.stopPatterns). +# Drop-in conflict-detection patterns (globs; converted to case-insensitive +# regex at build time for details.stopPatterns and the `stopPatterns` predicate). stopPatterns: - - { regex: '[^/]*\.cat$' } - - { regex: '[^/]*\.dat$' } - - { regex: '(^|/)t/[^/]+\.xml$' } - - { regex: '(^|/)lang\.dat$' } - - { regex: "(^|/)assets/.+" } - - { regex: '(^|/)libraries/.+\.xml$' } - - { regex: '(^|/)maps/.+\.xml$' } - - { regex: '(^|/)md/.+\.xml$' } - - { regex: "(^|/)cinematics/.+" } - - { regex: '(^|/)aiscripts/.+\.xml$' } - - { regex: '(^|/)voice-[^/]+/.+\.(ogg|wav)$' } - - { regex: "(^|/)ui/.+" } - - { regex: "(^|/)sfx/.+" } - - { regex: '[^/]*\.cur$' } - - { regex: '[^/]*\.(ogg|mp3|wav)$' } - - { regex: '[^/]*\.(mkv|mp4|webm)$' } - - { regex: '[^/]*\.ini$' } + # Game-data archives and root drop-ins. + - "**/*.cat" + - "**/*.dat" + - "**/*.cur" + - "**/*.ini" + - "**/*.{ogg,mp3,wav}" + - "**/*.{mkv,mp4,webm}" + # Translations and standard X Rebirth mod subfolders. + - "**/t/*.xml" + - "**/assets/**" + - "**/libraries/**/*.xml" + - "**/maps/**/*.xml" + - "**/md/**/*.xml" + - "**/cinematics/**" + - "**/aiscripts/**/*.xml" + - "**/voice-*/**/*.{ogg,wav}" + - "**/ui/**" + - "**/sfx/**" # Corpus testing: run the installers against real X Rebirth mod manifests # fetched from Nexus (`gdl test:corpus --fetch`). When the extension is built, From f6fe2eaadb6156619eaf01ed422c5202f9a49df8 Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Wed, 17 Jun 2026 10:19:38 -0700 Subject: [PATCH 08/12] xrebirth: single source for stop patterns The mod-shape health check no longer carries its own copy of the drop-in patterns. It reads them from the registered game's details.stopPatterns (which GDL populates from game.yaml's stopPatterns block), so game.yaml is the single source feeding details.stopPatterns, the drop-in installer predicate, and the health check. - hooks.ts: drop the hardcoded XREBIRTH_STOP_PATTERNS; resolve via util.getGame. - extension-test-mocks: add util.getGame + setMockGame so unit tests can supply the game's stop patterns. - bump GDL submodule (mock getGame support for corpus execution mode). Verified: extension unit tests pass, and the full corpus run is unchanged (299 matched / 3 unmatched / 0 failed, 0 health-check failures). --- .../games/game-xrebirth/src/hooks.test.ts | 14 +++++- extensions/games/game-xrebirth/src/hooks.ts | 44 +++++++------------ game-description-language | 2 +- packages/extension-test-mocks/src/index.ts | 23 ++++++++++ 4 files changed, 52 insertions(+), 31 deletions(-) diff --git a/extensions/games/game-xrebirth/src/hooks.test.ts b/extensions/games/game-xrebirth/src/hooks.test.ts index 3d7d4bcb7c..1e958a1274 100644 --- a/extensions/games/game-xrebirth/src/hooks.test.ts +++ b/extensions/games/game-xrebirth/src/hooks.test.ts @@ -1,7 +1,7 @@ import path from "node:path"; -import { setReadFileResolver } from "@vortex/extension-test-mocks"; -import { describe, test, it, expect, beforeEach } from "vitest"; +import { setReadFileResolver, setMockGame } from "@vortex/extension-test-mocks"; +import { describe, test, it, expect, beforeEach, beforeAll } from "vitest"; import { installContentXml, @@ -10,6 +10,16 @@ import { modShapeRecognisedCheck, } from "./hooks"; +// The mod-shape health check resolves stop patterns from the registered game's +// details.stopPatterns (single source: game.yaml). Register a representative +// set so the stopPatterns-recognition path is exercised. +beforeAll(() => { + setMockGame({ + id: "xrebirth", + details: { stopPatterns: ["[^/]*\\.cat$", "[^/]*\\.dat$", "(^|/)assets/.+"] }, + }); +}); + // --------------------------------------------------------------------------- // installContentXml // --------------------------------------------------------------------------- diff --git a/extensions/games/game-xrebirth/src/hooks.ts b/extensions/games/game-xrebirth/src/hooks.ts index df1487e5a3..c5181675ef 100644 --- a/extensions/games/game-xrebirth/src/hooks.ts +++ b/extensions/games/game-xrebirth/src/hooks.ts @@ -15,6 +15,8 @@ import { fs, types, util } from "@nexusmods/vortex-api"; import { parseStringPromise } from "xml2js"; /** Mod type ids, mirrored from game.yaml's modTypes block. */ +const XREBIRTH_GAME_ID = "xrebirth"; + const XREBIRTH_MOD_TYPES = { savegame: "xrebirth-savegame", shaderInjector: "xrebirth-shader-injector", @@ -24,30 +26,6 @@ const XREBIRTH_MOD_TYPES = { documentation: "xrebirth-documentation", } as const; -/** - * Drop-in stop patterns, mirrored from game.yaml's stopPatterns block (regex - * sources). Used by the mod-shape health check to recognise drop-in content. - */ -const XREBIRTH_STOP_PATTERNS: string[] = [ - "[^/]*\\.cat$", - "[^/]*\\.dat$", - "(^|/)t/[^/]+\\.xml$", - "(^|/)lang\\.dat$", - "(^|/)assets/.+", - "(^|/)libraries/.+\\.xml$", - "(^|/)maps/.+\\.xml$", - "(^|/)md/.+\\.xml$", - "(^|/)cinematics/.+", - "(^|/)aiscripts/.+\\.xml$", - "(^|/)voice-[^/]+/.+\\.(ogg|wav)$", - "(^|/)ui/.+", - "(^|/)sfx/.+", - "[^/]*\\.cur$", - "[^/]*\\.(ogg|mp3|wav)$", - "[^/]*\\.(mkv|mp4|webm)$", - "[^/]*\\.ini$", -]; - // --------------------------------------------------------------------------- // content.xml installer // --------------------------------------------------------------------------- @@ -137,9 +115,18 @@ const TRIGGERS: types.HealthCheckTrigger[] = [ const SEVERITY_INFO = types.HealthCheckSeverity.Info; const SEVERITY_WARNING = types.HealthCheckSeverity.Warning; -// Hoisted: the stopPatterns list is constant, so compile once instead of every -// check invocation. -const STOP_PATTERN_REGEXES = util.compileStopPatterns(XREBIRTH_STOP_PATTERNS); +// Single source of truth: the drop-in patterns live in game.yaml's +// `stopPatterns:` block, which the runtime publishes to `IGame.details.stopPatterns`. +// Read them back from the registered game (compiled once) rather than keeping a +// second copy here. +let stopPatternRegexes: RegExp[] | undefined; +const getStopPatternRegexes = (): RegExp[] => { + if (stopPatternRegexes === undefined) { + const patterns = util.getGame(XREBIRTH_GAME_ID)?.details?.stopPatterns ?? []; + stopPatternRegexes = util.compileStopPatterns(patterns); + } + return stopPatternRegexes; +}; function isContentXmlMod(mod: types.IModCheckContext): boolean { return mod.files.some((f) => path.basename(f).toLowerCase() === "content.xml"); @@ -263,7 +250,8 @@ export const modShapeRecognisedCheck: types.IModHealthCheck = { startedAt, ); } - if (mod.files.some((f) => STOP_PATTERN_REGEXES.some((re) => re.test(f)))) { + const stopRegexes = getStopPatternRegexes(); + if (mod.files.some((f) => stopRegexes.some((re) => re.test(f)))) { return passed("xrebirth-mod-shape-recognised", "Recognised by stopPatterns match", startedAt); } return warning( diff --git a/game-description-language b/game-description-language index c432726757..f3ef2dc007 160000 --- a/game-description-language +++ b/game-description-language @@ -1 +1 @@ -Subproject commit c432726757a03a3a5efcf892dfb61454e1ef67c1 +Subproject commit f3ef2dc007fe1651fe21fa51bbdbc85fddd481aa diff --git a/packages/extension-test-mocks/src/index.ts b/packages/extension-test-mocks/src/index.ts index adf8707cc1..d223dec774 100644 --- a/packages/extension-test-mocks/src/index.ts +++ b/packages/extension-test-mocks/src/index.ts @@ -237,6 +237,27 @@ function declareInstallers( } } +/** + * The game `util.getGame` returns. Tests that exercise code reading + * `getGame(id)?.details?.stopPatterns` (e.g. health checks) set it via + * `setMockGame`. Sequential test runs make a shared value safe. + */ +let mockGame: + | { id?: string; details?: { stopPatterns?: readonly string[] }; [key: string]: unknown } + | undefined; + +export function setMockGame( + game: + | { id?: string; details?: { stopPatterns?: readonly string[] }; [key: string]: unknown } + | undefined, +): void { + mockGame = game; +} + +function getGame(gameId: string): typeof mockGame { + return mockGame !== undefined && mockGame.id === gameId ? mockGame : undefined; +} + export const util: { DataInvalid: typeof DataInvalid; ProcessCanceled: typeof ProcessCanceled; @@ -246,6 +267,7 @@ export const util: { buildCopyInstructions: typeof buildCopyInstructions; compileStopPatterns: typeof compileStopPatterns; findCommonRootDir: typeof findCommonRootDir; + getGame: typeof getGame; } = { DataInvalid: DataInvalid, ProcessCanceled: ProcessCanceled, @@ -255,6 +277,7 @@ export const util: { buildCopyInstructions: buildCopyInstructions, compileStopPatterns: compileStopPatterns, findCommonRootDir: findCommonRootDir, + getGame: getGame, }; export const log: Mock = vi.fn(); From e28ce90aa0c9fa4fa57e34f68693a04f6d91fac5 Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Wed, 17 Jun 2026 11:11:03 -0700 Subject: [PATCH 09/12] xrebirth: inline drop-in globs, drop stopPatterns The drop-in installer now matches on inline globs (when/any) instead of the shared stopPatterns block, and the mod-shape health check recognises drop-ins by their xrebirth-dropin modType rather than re-deriving from patterns. That removes the indirection (one block feeding three consumers) for a game with a single real consumer. - game.yaml: inline the drop-in globs; remove the stopPatterns block. - hooks.ts: recognise drop-ins by modType; drop the getGame/stopPatterns lookup. - extension-test-mocks: revert the getGame/setMockGame test helper (unused). - bump GDL submodule (stopPatterns feature removed). Verified: corpus unchanged at 299 matched / 3 unmatched / 0 failed, 0 health failures; extension unit tests pass. --- extensions/games/game-xrebirth/game.yaml | 44 +++++++++---------- .../games/game-xrebirth/src/hooks.test.ts | 22 +++------- extensions/games/game-xrebirth/src/hooks.ts | 42 +++++------------- game-description-language | 2 +- packages/extension-test-mocks/src/index.ts | 23 ---------- 5 files changed, 37 insertions(+), 96 deletions(-) diff --git a/extensions/games/game-xrebirth/game.yaml b/extensions/games/game-xrebirth/game.yaml index 5435249a91..9e534d5ba2 100644 --- a/extensions/games/game-xrebirth/game.yaml +++ b/extensions/games/game-xrebirth/game.yaml @@ -71,10 +71,28 @@ installers: copy: { stripCommonRoot: true } modType: xrebirth-utility - # 75 - looks like an X Rebirth drop-in (matches a stopPattern). + # 75 - looks like an X Rebirth drop-in: loose game content (archives, game + # subfolders, audio/video/config) that deploys into extensions/ as-is. - id: dropin priority: 75 - when: { stopPatterns: true } + when: + any: + - { hasFile: "**/*.cat" } + - { hasFile: "**/*.dat" } + - { hasFile: "**/*.cur" } + - { hasFile: "**/*.ini" } + - { hasFile: "**/*.{ogg,mp3,wav}" } + - { hasFile: "**/*.{mkv,mp4,webm}" } + - { hasFile: "**/t/*.xml" } + - { hasFile: "**/assets/**" } + - { hasFile: "**/libraries/**/*.xml" } + - { hasFile: "**/maps/**/*.xml" } + - { hasFile: "**/md/**/*.xml" } + - { hasFile: "**/cinematics/**" } + - { hasFile: "**/aiscripts/**/*.xml" } + - { hasFile: "**/voice-*/**/*.{ogg,wav}" } + - { hasFile: "**/ui/**" } + - { hasFile: "**/sfx/**" } copy: { stripCommonRoot: true } modType: xrebirth-dropin @@ -114,28 +132,6 @@ installers: copy: { stripCommonRoot: true } modType: xrebirth-documentation -# Drop-in conflict-detection patterns (globs; converted to case-insensitive -# regex at build time for details.stopPatterns and the `stopPatterns` predicate). -stopPatterns: - # Game-data archives and root drop-ins. - - "**/*.cat" - - "**/*.dat" - - "**/*.cur" - - "**/*.ini" - - "**/*.{ogg,mp3,wav}" - - "**/*.{mkv,mp4,webm}" - # Translations and standard X Rebirth mod subfolders. - - "**/t/*.xml" - - "**/assets/**" - - "**/libraries/**/*.xml" - - "**/maps/**/*.xml" - - "**/md/**/*.xml" - - "**/cinematics/**" - - "**/aiscripts/**/*.xml" - - "**/voice-*/**/*.{ogg,wav}" - - "**/ui/**" - - "**/sfx/**" - # Corpus testing: run the installers against real X Rebirth mod manifests # fetched from Nexus (`gdl test:corpus --fetch`). When the extension is built, # the corpus drives the real installer chain (including the content.xml hook) diff --git a/extensions/games/game-xrebirth/src/hooks.test.ts b/extensions/games/game-xrebirth/src/hooks.test.ts index 1e958a1274..6e7f6fadae 100644 --- a/extensions/games/game-xrebirth/src/hooks.test.ts +++ b/extensions/games/game-xrebirth/src/hooks.test.ts @@ -1,7 +1,7 @@ import path from "node:path"; -import { setReadFileResolver, setMockGame } from "@vortex/extension-test-mocks"; -import { describe, test, it, expect, beforeEach, beforeAll } from "vitest"; +import { setReadFileResolver } from "@vortex/extension-test-mocks"; +import { describe, test, it, expect, beforeEach } from "vitest"; import { installContentXml, @@ -10,16 +10,6 @@ import { modShapeRecognisedCheck, } from "./hooks"; -// The mod-shape health check resolves stop patterns from the registered game's -// details.stopPatterns (single source: game.yaml). Register a representative -// set so the stopPatterns-recognition path is exercised. -beforeAll(() => { - setMockGame({ - id: "xrebirth", - details: { stopPatterns: ["[^/]*\\.cat$", "[^/]*\\.dat$", "(^|/)assets/.+"] }, - }); -}); - // --------------------------------------------------------------------------- // installContentXml // --------------------------------------------------------------------------- @@ -161,17 +151,17 @@ describe("modShapeRecognisedCheck", () => { expect(result.message).toContain("xrebirth-utility"); }); - test("recognised by stopPattern match", async () => { + test("recognised by drop-in modType", async () => { const result = await modShapeRecognisedCheck.checkMod(STUB_API, { ...ctx, files: ["data.cat"], - attributes: {}, + attributes: { modType: "xrebirth-dropin" }, }); expect(result.status).toBe("passed"); - expect(result.message).toMatch(/stopPatterns/); + expect(result.message).toContain("xrebirth-dropin"); }); - test("warns when nothing recognised", async () => { + test("warns when neither content.xml nor a recognised modType", async () => { const result = await modShapeRecognisedCheck.checkMod(STUB_API, { ...ctx, files: ["random.bin"], diff --git a/extensions/games/game-xrebirth/src/hooks.ts b/extensions/games/game-xrebirth/src/hooks.ts index c5181675ef..dc9731adbe 100644 --- a/extensions/games/game-xrebirth/src/hooks.ts +++ b/extensions/games/game-xrebirth/src/hooks.ts @@ -15,8 +15,6 @@ import { fs, types, util } from "@nexusmods/vortex-api"; import { parseStringPromise } from "xml2js"; /** Mod type ids, mirrored from game.yaml's modTypes block. */ -const XREBIRTH_GAME_ID = "xrebirth"; - const XREBIRTH_MOD_TYPES = { savegame: "xrebirth-savegame", shaderInjector: "xrebirth-shader-injector", @@ -99,13 +97,11 @@ export async function installContentXml( // health checks // --------------------------------------------------------------------------- -const TAGGED_NON_CONTENT_XML = new Set([ - XREBIRTH_MOD_TYPES.savegame, - XREBIRTH_MOD_TYPES.shaderInjector, - XREBIRTH_MOD_TYPES.utility, - XREBIRTH_MOD_TYPES.documentation, - XREBIRTH_MOD_TYPES.savePatch, -]); +// Every mod our installers produce is tagged with one of these modTypes (the +// content.xml installer is the exception and is recognised separately). The +// mod-shape health check uses this set to confirm an installed mod has a +// recognised X Rebirth shape. +const RECOGNISED_MOD_TYPES = new Set(Object.values(XREBIRTH_MOD_TYPES)); const CATEGORY = types.HealthCheckCategory.Mods; const TRIGGERS: types.HealthCheckTrigger[] = [ @@ -115,19 +111,6 @@ const TRIGGERS: types.HealthCheckTrigger[] = [ const SEVERITY_INFO = types.HealthCheckSeverity.Info; const SEVERITY_WARNING = types.HealthCheckSeverity.Warning; -// Single source of truth: the drop-in patterns live in game.yaml's -// `stopPatterns:` block, which the runtime publishes to `IGame.details.stopPatterns`. -// Read them back from the registered game (compiled once) rather than keeping a -// second copy here. -let stopPatternRegexes: RegExp[] | undefined; -const getStopPatternRegexes = (): RegExp[] => { - if (stopPatternRegexes === undefined) { - const patterns = util.getGame(XREBIRTH_GAME_ID)?.details?.stopPatterns ?? []; - stopPatternRegexes = util.compileStopPatterns(patterns); - } - return stopPatternRegexes; -}; - function isContentXmlMod(mod: types.IModCheckContext): boolean { return mod.files.some((f) => path.basename(f).toLowerCase() === "content.xml"); } @@ -225,15 +208,14 @@ export const contentXmlCustomFileNameCheck: types.IModHealthCheck = { /** * The mod must look like *some* recognisable X Rebirth shape: a content.xml - * mod, a drop-in matching the game's stopPatterns, or one of the tagged - * non-content modTypes (savegame, utility, shader, etc.). Otherwise the - * installer matched something but it isn't actually X Rebirth content. + * mod, or one tagged with a known modType (savegame, drop-in, utility, shader, + * etc.). Otherwise the installer matched something that isn't X Rebirth content. */ export const modShapeRecognisedCheck: types.IModHealthCheck = { id: "xrebirth-mod-shape-recognised", name: "X Rebirth - mod has a recognisable shape", description: - "Verifies the install output is content.xml, matches stopPatterns, or is tagged with a known modType.", + "Verifies the install output is a content.xml mod or is tagged with a known modType.", category: CATEGORY, severity: SEVERITY_WARNING, triggers: TRIGGERS, @@ -243,21 +225,17 @@ export const modShapeRecognisedCheck: types.IModHealthCheck = { return passed("xrebirth-mod-shape-recognised", "Recognised as content.xml mod", startedAt); } const modType = mod.attributes.modType as string | undefined; - if (modType !== undefined && TAGGED_NON_CONTENT_XML.has(modType)) { + if (modType !== undefined && RECOGNISED_MOD_TYPES.has(modType)) { return passed( "xrebirth-mod-shape-recognised", `Recognised by modType: ${modType}`, startedAt, ); } - const stopRegexes = getStopPatternRegexes(); - if (mod.files.some((f) => stopRegexes.some((re) => re.test(f)))) { - return passed("xrebirth-mod-shape-recognised", "Recognised by stopPatterns match", startedAt); - } return warning( "xrebirth-mod-shape-recognised", "Install output has no recognisable X Rebirth shape", - "No content.xml, no stop-pattern matches, and no recognised modType.", + "No content.xml and no recognised modType.", startedAt, ); }, diff --git a/game-description-language b/game-description-language index f3ef2dc007..adb52c5ca8 160000 --- a/game-description-language +++ b/game-description-language @@ -1 +1 @@ -Subproject commit f3ef2dc007fe1651fe21fa51bbdbc85fddd481aa +Subproject commit adb52c5ca8128116330d981f8b988f7439784d6d diff --git a/packages/extension-test-mocks/src/index.ts b/packages/extension-test-mocks/src/index.ts index d223dec774..adf8707cc1 100644 --- a/packages/extension-test-mocks/src/index.ts +++ b/packages/extension-test-mocks/src/index.ts @@ -237,27 +237,6 @@ function declareInstallers( } } -/** - * The game `util.getGame` returns. Tests that exercise code reading - * `getGame(id)?.details?.stopPatterns` (e.g. health checks) set it via - * `setMockGame`. Sequential test runs make a shared value safe. - */ -let mockGame: - | { id?: string; details?: { stopPatterns?: readonly string[] }; [key: string]: unknown } - | undefined; - -export function setMockGame( - game: - | { id?: string; details?: { stopPatterns?: readonly string[] }; [key: string]: unknown } - | undefined, -): void { - mockGame = game; -} - -function getGame(gameId: string): typeof mockGame { - return mockGame !== undefined && mockGame.id === gameId ? mockGame : undefined; -} - export const util: { DataInvalid: typeof DataInvalid; ProcessCanceled: typeof ProcessCanceled; @@ -267,7 +246,6 @@ export const util: { buildCopyInstructions: typeof buildCopyInstructions; compileStopPatterns: typeof compileStopPatterns; findCommonRootDir: typeof findCommonRootDir; - getGame: typeof getGame; } = { DataInvalid: DataInvalid, ProcessCanceled: ProcessCanceled, @@ -277,7 +255,6 @@ export const util: { buildCopyInstructions: buildCopyInstructions, compileStopPatterns: compileStopPatterns, findCommonRootDir: findCommonRootDir, - getGame: getGame, }; export const log: Mock = vi.fn(); From 71f8de2617f4bc1064bcbddaa04f98bd2d3eb457 Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Wed, 17 Jun 2026 12:03:44 -0700 Subject: [PATCH 10/12] chore: bump game-description-language submodule to squashed HEAD --- game-description-language | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/game-description-language b/game-description-language index adb52c5ca8..5a2053424a 160000 --- a/game-description-language +++ b/game-description-language @@ -1 +1 @@ -Subproject commit adb52c5ca8128116330d981f8b988f7439784d6d +Subproject commit 5a2053424a5ae01767d4ec228a65e6119362d331 From 1023fde5cd3e42698a96fc50576601b50cb218fa Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Wed, 17 Jun 2026 12:30:53 -0700 Subject: [PATCH 11/12] chore: bump game-description-language submodule (rebased onto main) --- game-description-language | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/game-description-language b/game-description-language index 5a2053424a..342198901f 160000 --- a/game-description-language +++ b/game-description-language @@ -1 +1 @@ -Subproject commit 5a2053424a5ae01767d4ec228a65e6119362d331 +Subproject commit 342198901ffdeaafeaccaa31ccaa4cb9c0cad55a From 1a4b9dba7fdda459121910fb4d27f41284e8dd61 Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Wed, 17 Jun 2026 12:51:20 -0700 Subject: [PATCH 12/12] chore: track game-description-language main after PR #2 merge --- .gitmodules | 2 +- game-description-language | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index 1c2906ba26..cb25232ba0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "game-description-language"] path = game-description-language url = https://github.com/Nexus-Mods/game-description-language.git - branch = feat/vortex-xrebirth-integration + branch = main diff --git a/game-description-language b/game-description-language index 342198901f..b1aab385ef 160000 --- a/game-description-language +++ b/game-description-language @@ -1 +1 @@ -Subproject commit 342198901ffdeaafeaccaa31ccaa4cb9c0cad55a +Subproject commit b1aab385efbc4a81de1692db1b423c9d6b178346