diff --git a/.gitignore b/.gitignore index 023a303d11..99a7559b38 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ dist dist_custom dist_portable dist_web +.gdl-out +**/tests/cache out doc coverage diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..cb25232ba0 --- /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 = main diff --git a/extensions/games/game-xrebirth/build.mjs b/extensions/games/game-xrebirth/build.mjs index 9af8003161..c647d46a0d 100644 --- a/extensions/games/game-xrebirth/build.mjs +++ b/extensions/games/game-xrebirth/build.mjs @@ -1,10 +1,9 @@ import * as path from "node:path"; -import { createConfig, bundle } from "../../../scripts/extensions-rolldown.mjs"; +import { buildGdlExtension } from "../../../scripts/build-gdl-extension.mjs"; -const extensionPath = path.resolve(import.meta.dirname); -const entryPoint = path.resolve(extensionPath, "src", "index.ts"); -const output = path.resolve(extensionPath, "dist", "index.cjs"); - -const config = createConfig(entryPoint, output); -await bundle(config); +// 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. +await buildGdlExtension(path.resolve(import.meta.dirname)); diff --git a/extensions/games/game-xrebirth/game.yaml b/extensions/games/game-xrebirth/game.yaml new file mode 100644 index 0000000000..9e534d5ba2 --- /dev/null +++ b/extensions/games/game-xrebirth/game.yaml @@ -0,0 +1,149 @@ +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: loose game content (archives, game + # subfolders, audio/video/config) that deploys into extensions/ as-is. + - id: dropin + priority: 75 + 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 + + # 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 + +# 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: + - 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..5cc0dcd26e 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.", @@ -14,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": [ @@ -37,16 +32,6 @@ "typescript", "vortex-api" ] - }, - "test:game-extensions": { - "executor": "nx:run-commands", - "options": { - "command": "pnpm --filter @vortex/game-extension-test run start -- --game game-xrebirth" - }, - "inputs": [ - "{projectRoot}/src/**/*.ts" - ], - "cache": false } } } 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..6e7f6fadae --- /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 drop-in modType", async () => { + const result = await modShapeRecognisedCheck.checkMod(STUB_API, { + ...ctx, + files: ["data.cat"], + attributes: { modType: "xrebirth-dropin" }, + }); + expect(result.status).toBe("passed"); + expect(result.message).toContain("xrebirth-dropin"); + }); + + test("warns when neither content.xml nor a recognised modType", 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..dc9731adbe --- /dev/null +++ b/extensions/games/game-xrebirth/src/hooks.ts @@ -0,0 +1,242 @@ +/** + * 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; + +// --------------------------------------------------------------------------- +// 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 { + // 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), { + 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 +// --------------------------------------------------------------------------- + +// 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[] = [ + types.HealthCheckTrigger.ModsChanged, + types.HealthCheckTrigger.Manual, +]; +const SEVERITY_INFO = types.HealthCheckSeverity.Info; +const SEVERITY_WARNING = types.HealthCheckSeverity.Warning; + +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, 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 a content.xml mod 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 && RECOGNISED_MOD_TYPES.has(modType)) { + return passed( + "xrebirth-mod-shape-recognised", + `Recognised by modType: ${modType}`, + startedAt, + ); + } + return warning( + "xrebirth-mod-shape-recognised", + "Install output has no recognisable X Rebirth shape", + "No content.xml 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/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/game-description-language b/game-description-language new file mode 160000 index 0000000000..b1aab385ef --- /dev/null +++ b/game-description-language @@ -0,0 +1 @@ +Subproject commit b1aab385efbc4a81de1692db1b423c9d6b178346 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 65be363165..0000000000 --- a/packages/game-extension-test/src/loadExtension.ts +++ /dev/null @@ -1,198 +0,0 @@ -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; - registerGame: (game: IHarnessGame) => void; - registerInstaller: ( - id: string, - priority: number, - testSupported: HarnessTestSupported, - install: HarnessInstall, - ) => void; - once: (cb: () => void) => void; - api: Record; - [hook: string]: unknown; -} - -export async function loadExtension(extensionDir: string): Promise { - const stubContext = makeStubContext(); - const indexPath = path.join(extensionDir, "src", "index.ts"); - const mod = await import(indexPath); - const init = mod.default ?? mod.init; - 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; - }; - - // 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; - } - } - - 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.`, - ); - } - - const healthChecks = resolveHealthChecks(diagnosticMod); - if (healthChecks.length === 0) { - throw new Error( - `Extension ${extensionDir} exports testDescriptor but has no health checks ` + - `(expected at src/diagnostic.ts: export const healthChecks = [...] ` + - `with at least one IModHealthCheck). ` + - `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, - registerGame(game) { - base._game = game; - }, - registerInstaller(id, priority, testSupported, install) { - base._installers.push({ id, priority, testSupported, install }); - }, - 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 58fdbb9f90..0000000000 --- a/packages/game-extension-test/vitest.fixtures.config.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createRequire } from "node:module"; - -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"); - -// 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 }], - }, - 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: 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 }); +}