diff --git a/quasar.config.ts b/quasar.config.ts index 886cf8f0d..12b51338c 100644 --- a/quasar.config.ts +++ b/quasar.config.ts @@ -18,7 +18,8 @@ export default defineConfig((ctx) => { boot: [ 'i18n', // 'axios', - 'floating-vue' + 'floating-vue', + 'ecosystem' ], // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css diff --git a/src/boot/ecosystem.ts b/src/boot/ecosystem.ts new file mode 100644 index 000000000..ccf147c5c --- /dev/null +++ b/src/boot/ecosystem.ts @@ -0,0 +1,12 @@ +import { defineBoot } from '#q-app/wrappers'; +import {updateEcosystemReactives} from "src/r2mm/ecosystem/EcosystemSchema"; +import FsProvider from "src/providers/generic/file/FsProvider"; +import {NodeFsImplementation} from "src/providers/node/fs/NodeFsImplementation"; + +// @ts-ignore +export default defineBoot(async ({ app }) => { + FsProvider.provide(() => NodeFsImplementation); + await updateEcosystemReactives(); + // @ts-ignore + FsProvider.provide(() => undefined); +}); diff --git a/src/model/game/GameManager.ts b/src/model/game/GameManager.ts index 757eef973..8f5196163 100644 --- a/src/model/game/GameManager.ts +++ b/src/model/game/GameManager.ts @@ -2,7 +2,7 @@ import Game from '../../model/game/Game'; import StorePlatformMetadata from '../../model/game/StorePlatformMetadata'; import PathResolver from '../../r2mm/manager/PathResolver'; import FileUtils from '../../utils/FileUtils'; -import { EcosystemSchema, Platform } from '../schema/ThunderstoreSchema'; +import {EcosystemSupportedGames, Platform} from '../schema/ThunderstoreSchema'; import path from '../../providers/node/path/path'; export default class GameManager { @@ -23,7 +23,7 @@ export default class GameManager { } static get gameList(): Game[] { - return EcosystemSchema.supportedGames.map(([identifier, game]) => new Game( + return EcosystemSupportedGames.value.map(([identifier, game]) => new Game( game.meta.displayName, game.internalFolderName, game.settingsIdentifier, diff --git a/src/model/schema/ThunderstoreSchema.ts b/src/model/schema/ThunderstoreSchema.ts index 2af174ad1..46ffa88e1 100644 --- a/src/model/schema/ThunderstoreSchema.ts +++ b/src/model/schema/ThunderstoreSchema.ts @@ -1,10 +1,5 @@ -import Ajv from "ajv"; -import addFormats from "ajv-formats"; - -import ecosystem from "../../assets/data/ecosystem.json"; -import { R2Modman, ThunderstoreEcosystem } from "../../assets/data/ecosystemTypes"; -import jsonSchema from "../../assets/data/ecosystemJsonSchema.json"; -import R2Error from "../errors/R2Error"; +import {ModloaderPackage, R2Modman} from "../../assets/data/ecosystemTypes"; +import {ref} from "@vue/reactivity"; // Re-export generated types/Enums to avoid having the whole codebase // tightly coupled with the generated ecosystemTypes. @@ -16,47 +11,5 @@ export { Platform, } from "../../assets/data/ecosystemTypes"; -export class EcosystemSchema { - private static _isValidated: boolean = false; - - /** - * Get a validated instance of the ecosystem schema. - */ - private static get ecosystem(): ThunderstoreEcosystem { - if (this._isValidated) { - return ecosystem as ThunderstoreEcosystem; - } - - // Validate the schema via its schema schema. - const ajv = new Ajv(); - addFormats(ajv); - - const validate = ajv.compile(jsonSchema); - const isOk = validate(ecosystem); - - if (!isOk) { - throw new R2Error("Schema validation error", ajv.errorsText(validate.errors)); - } - - this._isValidated = true; - return ecosystem as ThunderstoreEcosystem; - } - - /** - * Get a list of [identifier, r2modman] entries i.e. games supported by the mod manager. - */ - static get supportedGames() { - const result: [string, R2Modman][] = [] - for (const [identifier, game] of Object.entries(this.ecosystem.games)) { - if (game.r2modman == null) continue; - for (const entry of game.r2modman) { - result.push([identifier, entry]); - } - } - return result; - } - - static get modloaderPackages() { - return this.ecosystem.modloaderPackages; - } -} +export const EcosystemSupportedGames = ref<[string, R2Modman][]>([]); +export const EcosystemModloaderPackages = ref([]); diff --git a/src/pages/GameSelectionScreen.vue b/src/pages/GameSelectionScreen.vue index 2221ea650..a601a74f2 100644 --- a/src/pages/GameSelectionScreen.vue +++ b/src/pages/GameSelectionScreen.vue @@ -189,6 +189,7 @@ import { getStore } from '../providers/generic/store/StoreProvider'; import { State } from '../store'; import { useRouter } from 'vue-router'; import ProtocolProvider from '../providers/generic/protocol/ProtocolProvider'; +import {updateEcosystemReactives, updateLatestEcosystemSchema} from "src/r2mm/ecosystem/EcosystemSchema"; const store = getStore(); const router = useRouter(); @@ -203,7 +204,6 @@ const settings = ref(undefined); const isSettingDefaultPlatform = ref(false); const viewMode = ref(GameSelectionViewMode.LIST); const activeTab = ref(GameInstanceType.GAME); -const gameImages = reactive({}); const filteredGameList = computed(() => { const displayNameInAdditionalSearch = (game: Game, filterText: string): boolean => { @@ -369,6 +369,9 @@ onMounted(async () => { await store.dispatch('resetLocalState'); + // TODO - Enable once updating is viable + // updateLatestEcosystemSchema(); + settings.value = await ManagerSettings.getSingleton(GameManager.defaultGame); const globalSettings = settings.value.getContext().global; favourites.value = globalSettings.favouriteGames || []; @@ -384,12 +387,12 @@ onMounted(async () => { } // Skip game selection view if valid default game & platform are set. - const {defaultGame, defaultPlatform} = ManagerUtils.getDefaults(settings.value); + const {defaultGame, defaultPlatform} = ManagerUtils.getDefaults(settings.value!); if (defaultGame && defaultPlatform) { selectedGame.value = defaultGame; selectedPlatform.value = defaultPlatform; - proceed(); + return proceed(); } }) diff --git a/src/r2mm/ecosystem/EcosystemMerge.ts b/src/r2mm/ecosystem/EcosystemMerge.ts new file mode 100644 index 000000000..a7c536e98 --- /dev/null +++ b/src/r2mm/ecosystem/EcosystemMerge.ts @@ -0,0 +1,261 @@ +import bundledEcosystem from "../../assets/data/ecosystem.json"; +import {R2Modman, ThunderstoreEcosystem} from "../../assets/data/ecosystemTypes"; +import jsonSchema from "../../assets/data/ecosystemJsonSchema.json"; +import R2Error from "../../model/errors/R2Error"; +import Ajv from "ajv"; +import addFormats from "ajv-formats"; +import PathResolver from "../manager/PathResolver"; +import path from "../../providers/node/path/path"; +import FsProvider from "../../providers/generic/file/FsProvider"; +import VersionNumber from "../../model/VersionNumber"; +import ManagerInformation from "../../_managerinf/ManagerInformation"; +import {EcosystemModloaderPackages, EcosystemSupportedGames} from "../../model/schema/ThunderstoreSchema"; +import {updateModLoaderExports} from "../installing/profile_installers/ModLoaderVariantRecord"; +import {getAxiosWithTimeouts} from "../../utils/HttpUtils"; +import {retry} from "../../utils/Common"; + +type EcosystemCacheMetadata = { + version: string; + lastCheckedAt?: number; + lastModified?: string; +}; + +type EcosystemCacheWriteMetadata = { + lastCheckedAt?: number; + lastModified?: string; +}; + +type LatestSchemaFetchResult = + | {kind: "skipped"} + | {kind: "not-modified", lastCheckedAt: number, lastModified?: string} + | {kind: "fetched", schema: ThunderstoreEcosystem, lastCheckedAt: number, lastModified?: string} + | {kind: "failed"}; + +export type MergedThunderstoreEcosystem = ThunderstoreEcosystem & EcosystemCacheMetadata; + +const ECOSYSTEM_DATA_URL = "https://thunderstore.io/api/experimental/schema/dev/latest/"; +const ECOSYSTEM_REFRESH_INTERVAL_MS = 12 * 60 * 60 * 1000; + +function createEmptySchema(): ThunderstoreEcosystem { + return { + schemaVersion: "", + communities: {}, + games: {}, + modloaderPackages: [], + packageInstallers: {}, + }; +} + +function validateSchema(schema: unknown): ThunderstoreEcosystem { + const ajv = new Ajv(); + addFormats(ajv); + + const validate = ajv.compile(jsonSchema); + const isOk = validate(schema); + + if (!isOk) { + throw new R2Error("Schema validation error", ajv.errorsText(validate.errors)); + } + + return schema as ThunderstoreEcosystem; +} + +function isSchemaRefreshDue(schema: MergedThunderstoreEcosystem | null): boolean { + if (schema == null || schema.lastCheckedAt == null) { + return true; + } + return (Date.now() - schema.lastCheckedAt) >= ECOSYSTEM_REFRESH_INTERVAL_MS; +} + +function mergeSchemas( + bundledSchema: ThunderstoreEcosystem, + latestSchema: ThunderstoreEcosystem +): ThunderstoreEcosystem { + const mergedSchema: ThunderstoreEcosystem = createEmptySchema(); + mergedSchema.schemaVersion = latestSchema.schemaVersion; + mergedSchema.communities = { + ...bundledSchema.communities, + ...latestSchema.communities, + } + mergedSchema.games = { + ...bundledSchema.games, + ...latestSchema.games, + }; + // Convert to map to handle duplicate keys nicely + const modloaderMap = new Map( + [...bundledSchema.modloaderPackages, ...latestSchema.modloaderPackages] + .map(pkg => [pkg.packageId, pkg]) + ); + mergedSchema.modloaderPackages = [...modloaderMap.values()]; + mergedSchema.packageInstallers = { + ...bundledSchema.packageInstallers, + ...latestSchema.packageInstallers, + }; + return mergedSchema; +} + +function createCacheMetadata( + lastCheckedAt: number, + lastModified?: string +): EcosystemCacheWriteMetadata { + const metadata: EcosystemCacheWriteMetadata = {lastCheckedAt}; + if (lastModified != null) { + metadata.lastModified = lastModified; + } + return metadata; +} + +async function getMergedEcosystemPath(): Promise { + return path.join(PathResolver.ROOT, "ecosystem-merge.json"); +} + +export async function updateLatestMergedEcosystemSchema(): Promise { + const bundledSchema = await loadBundledSchema(); + const currentMergedSchema = await getValidSavedMergedEcosystemSchema(); + const latestSchema = await fetchLatestSchema(currentMergedSchema); + + if (latestSchema.kind === "skipped") { + return; + } + + if (latestSchema.kind === "not-modified") { + if (currentMergedSchema == null) { + return; + } + + const lastModified = latestSchema.lastModified ?? currentMergedSchema.lastModified; + await writeLatestMergedEcosystemSchema( + currentMergedSchema, + createCacheMetadata(latestSchema.lastCheckedAt, lastModified) + ); + return; + } + + if (latestSchema.kind === "failed") { + if (currentMergedSchema != null) { + return; + } + + const mergedSchema = mergeSchemas(bundledSchema, createEmptySchema()); + await writeLatestMergedEcosystemSchema(mergedSchema); + await internalUpdateEcosystemReactives(mergedSchema); + return; + } + + const mergedSchema = mergeSchemas(bundledSchema, latestSchema.schema); + await writeLatestMergedEcosystemSchema( + mergedSchema, + createCacheMetadata(latestSchema.lastCheckedAt, latestSchema.lastModified) + ); + await internalUpdateEcosystemReactives(mergedSchema); +} + +async function writeLatestMergedEcosystemSchema( + schema: ThunderstoreEcosystem, + metadata?: EcosystemCacheWriteMetadata +): Promise { + const asMergedSchema: MergedThunderstoreEcosystem = { + ...schema, + version: ManagerInformation.VERSION.toString(), + ...(metadata || {}), + }; + const writable = JSON.stringify(asMergedSchema); + return FsProvider.instance.writeFile(await getMergedEcosystemPath(), writable); +} + +async function getLastSavedMergedEcosystemSchema(): Promise { + const contentBuffer = await FsProvider.instance.readFile(await getMergedEcosystemPath()); + const content = contentBuffer.toString("utf8"); + return JSON.parse(content); +} + +async function loadBundledSchema(): Promise { + return validateSchema(bundledEcosystem); +} + +async function fetchLatestSchema( + currentSchema: MergedThunderstoreEcosystem | null +): Promise { + if (!isSchemaRefreshDue(currentSchema)) { + return {kind: "skipped"}; + } + + const timeout = 5000; + const options = {attempts: 3, interval: 1000, throwLastErrorAsIs: true}; + const requestConfig = { + validateStatus: (status: number) => status === 304 || (status >= 200 && status < 300), + ...(currentSchema?.lastModified ? {headers: {"If-Modified-Since": currentSchema.lastModified}} : {}), + }; + + try { + const axios = getAxiosWithTimeouts(timeout, timeout * 2); + const response = await retry( + () => axios.get(ECOSYSTEM_DATA_URL, requestConfig), + options + ); + const lastCheckedAt = Date.now(); + const lastModified = typeof response.headers["last-modified"] === "string" + ? response.headers["last-modified"] + : undefined; + + if (response.status === 304) { + return { + kind: "not-modified", + lastCheckedAt, + ...(currentSchema?.lastModified ? {lastModified: currentSchema.lastModified} : {}), + }; + } + + return { + kind: "fetched", + schema: validateSchema(response.data), + lastCheckedAt, + ...(lastModified ? {lastModified} : {}), + }; + } catch (e) { + console.error(e); + return {kind: "failed"}; + } +} + +async function getValidSavedMergedEcosystemSchema(): Promise { + const mergeFilePath = await getMergedEcosystemPath(); + if (!(await FsProvider.instance.exists(mergeFilePath))) { + return null; + } + + const content = await getLastSavedMergedEcosystemSchema(); + if (!new VersionNumber(content.version).isEqualTo(ManagerInformation.VERSION)) { + return null; + } + + return content; +} + +async function resolveMergedEcosystemSchema(): Promise { + const bundledSchema = async () => ({...(await loadBundledSchema()), version: ManagerInformation.VERSION.toString()}); + const content = await getValidSavedMergedEcosystemSchema(); + if (content == null) { + return bundledSchema(); + } + + return content; +} + +async function internalUpdateEcosystemReactives(schema: ThunderstoreEcosystem): Promise { + const result: [string, R2Modman][] = [] + for (const [identifier, game] of Object.entries(schema.games)) { + if (game.r2modman == null) continue; + for (const entry of game.r2modman) { + result.push([identifier, entry]); + } + } + EcosystemSupportedGames.value = result; + EcosystemModloaderPackages.value = schema.modloaderPackages; + updateModLoaderExports(); +} + +export async function updateEcosystemReactives() { + const mergedSchema = await resolveMergedEcosystemSchema(); + await internalUpdateEcosystemReactives(mergedSchema); +} diff --git a/src/r2mm/ecosystem/EcosystemSchema.ts b/src/r2mm/ecosystem/EcosystemSchema.ts new file mode 100644 index 000000000..6678a9d24 --- /dev/null +++ b/src/r2mm/ecosystem/EcosystemSchema.ts @@ -0,0 +1,96 @@ +import bundledEcosystem from "../../assets/data/ecosystem.json"; +import {R2Modman, ThunderstoreEcosystem} from "../../assets/data/ecosystemTypes"; +import jsonSchema from "../../assets/data/ecosystemJsonSchema.json"; +import R2Error from "../../model/errors/R2Error"; +import Ajv from "ajv"; +import addFormats from "ajv-formats"; +import PathResolver from "../manager/PathResolver"; +import path from "../../providers/node/path/path"; +import FsProvider from "../../providers/generic/file/FsProvider"; +import VersionNumber from "../../model/VersionNumber"; +import ManagerInformation from "../../_managerinf/ManagerInformation"; +import {EcosystemModloaderPackages, EcosystemSupportedGames} from "../../model/schema/ThunderstoreSchema"; +import {updateModLoaderExports} from "../installing/profile_installers/ModLoaderVariantRecord"; + +export type VersionedThunderstoreEcosystem = ThunderstoreEcosystem & {version: string}; + +async function getMergedEcosystemPath(): Promise { + return path.join(PathResolver.ROOT, "latest-ecosystem-schema.json"); +} + +export async function updateLatestEcosystemSchema(): Promise { + const latestSchema = await fetchLatestSchema(); + await writeLatestEcosystemSchema(latestSchema); + await internalUpdateEcosystemReactives(latestSchema); +} + +async function writeLatestEcosystemSchema(schema: ThunderstoreEcosystem): Promise { + const asMergedSchema: VersionedThunderstoreEcosystem = { + ...schema, + version: ManagerInformation.VERSION.toString(), + }; + const writable = JSON.stringify(asMergedSchema); + return FsProvider.instance.writeFile(await getMergedEcosystemPath(), writable); +} + +async function getLastSavedEcosystemSchema(): Promise { + const contentBuffer = await FsProvider.instance.readFile(await getMergedEcosystemPath()); + const content = contentBuffer.toString("utf8"); + return JSON.parse(content); +} + +async function loadBundledSchema(): Promise { + const ajv = new Ajv(); + addFormats(ajv); + + const validate = ajv.compile(jsonSchema); + const isOk = validate(bundledEcosystem); + + if (!isOk) { + throw new R2Error("Schema validation error", ajv.errorsText(validate.errors)); + } + + return bundledEcosystem as ThunderstoreEcosystem; +} + +async function fetchLatestSchema(): Promise { + // TODO - Implement fetching of latest resource + return { + schemaVersion: "", + communities: {}, + games: {}, + modloaderPackages: [], + packageInstallers: {}, + }; +} + +async function resolveCachedEcosystemSchema(): Promise { + const mergeFilePath = await getMergedEcosystemPath(); + const bundledSchema = async () => ({...(await loadBundledSchema()), version: ManagerInformation.VERSION.toString()}); + if (!(await FsProvider.instance.exists(mergeFilePath))) { + return bundledSchema(); + } + const content = await getLastSavedEcosystemSchema(); + if (!new VersionNumber(content.version).isEqualTo(ManagerInformation.VERSION)) { + return bundledSchema(); + } + return content; +} + +async function internalUpdateEcosystemReactives(schema: ThunderstoreEcosystem): Promise { + const result: [string, R2Modman][] = [] + for (const [identifier, game] of Object.entries(schema.games)) { + if (game.r2modman == null) continue; + for (const entry of game.r2modman) { + result.push([identifier, entry]); + } + } + EcosystemSupportedGames.value = result; + EcosystemModloaderPackages.value = schema.modloaderPackages; + updateModLoaderExports(); +} + +export async function updateEcosystemReactives() { + const mergedSchema = await resolveCachedEcosystemSchema(); + await internalUpdateEcosystemReactives(mergedSchema); +} diff --git a/src/r2mm/installing/InstallationRules.ts b/src/r2mm/installing/InstallationRules.ts index 34f1b0931..5b10bb0ad 100644 --- a/src/r2mm/installing/InstallationRules.ts +++ b/src/r2mm/installing/InstallationRules.ts @@ -1,6 +1,6 @@ import { getPluginInstaller } from '../../installers/registry'; import GameManager from '../../model/game/GameManager'; -import { EcosystemSchema, TrackingMethod } from '../../model/schema/ThunderstoreSchema'; +import {EcosystemSupportedGames, TrackingMethod} from '../../model/schema/ThunderstoreSchema'; import path from '../../providers/node/path/path'; export type CoreRuleType = { @@ -39,7 +39,7 @@ export default class InstallationRules { } public static apply() { - this._RULES = EcosystemSchema.supportedGames.map(([_, x]) => ({ + this._RULES = EcosystemSupportedGames.value.map(([_, x]) => ({ gameName: x.internalFolderName, rules: x.installRules, relativeFileExclusions: x.relativeFileExclusions, diff --git a/src/r2mm/installing/profile_installers/ModLoaderVariantRecord.ts b/src/r2mm/installing/profile_installers/ModLoaderVariantRecord.ts index d79f1144d..7823399e9 100644 --- a/src/r2mm/installing/profile_installers/ModLoaderVariantRecord.ts +++ b/src/r2mm/installing/profile_installers/ModLoaderVariantRecord.ts @@ -1,17 +1,10 @@ import ModLoaderPackageMapping from '../../../model/installing/ModLoaderPackageMapping'; import VersionNumber from '../../../model/VersionNumber'; -import { EcosystemSchema, PackageLoader } from '../../../model/schema/ThunderstoreSchema'; - -/** - * A set of modloader packages read from the ecosystem schema. - */ -export const MODLOADER_PACKAGES = EcosystemSchema.modloaderPackages.map((x) => - new ModLoaderPackageMapping( - x.packageId, - x.rootFolder, - x.loader, - ), -); +import { + EcosystemModloaderPackages, + EcosystemSupportedGames, + PackageLoader +} from '../../../model/schema/ThunderstoreSchema'; type Modloaders = Record; @@ -28,16 +21,24 @@ const OVERRIDES: Modloaders = { ], } -export const MOD_LOADER_VARIANTS: Modloaders = Object.fromEntries( - EcosystemSchema.supportedGames - .map(([_, game]) => [ - game.internalFolderName, - OVERRIDES[game.internalFolderName] || MODLOADER_PACKAGES - ]) -); +export let MODLOADER_PACKAGES: ModLoaderPackageMapping[] = []; +export let MOD_LOADER_VARIANTS: Modloaders = {}; + +export function updateModLoaderExports() { + MODLOADER_PACKAGES = EcosystemModloaderPackages.value.map((x) => + new ModLoaderPackageMapping(x.packageId, x.rootFolder, x.loader) + ); + MOD_LOADER_VARIANTS = Object.fromEntries( + EcosystemSupportedGames.value + .map(([_, game]) => [ + game.internalFolderName, + OVERRIDES[game.internalFolderName] || MODLOADER_PACKAGES + ]) + ); +} export const getModLoaderPackageNames = () => { - const deduplicated = new Set(EcosystemSchema.modloaderPackages.map((x) => x.packageId)); + const deduplicated = new Set(EcosystemModloaderPackages.value.map((x) => x.packageId)); const names = Array.from(deduplicated); names.sort(); return names; diff --git a/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts b/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts new file mode 100644 index 000000000..d479fb911 --- /dev/null +++ b/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts @@ -0,0 +1,111 @@ +import * as path from 'path'; +import {beforeEach, describe, expect, test} from 'vitest'; + +import {VersionedThunderstoreEcosystem, updateEcosystemReactives, updateLatestEcosystemSchema} from 'src/r2mm/ecosystem/EcosystemSchema'; +import {EcosystemModloaderPackages, EcosystemSupportedGames} from '../../../../../src/model/schema/ThunderstoreSchema'; +import {MODLOADER_PACKAGES, MOD_LOADER_VARIANTS, updateModLoaderExports} from '../../../../../src/r2mm/installing/profile_installers/ModLoaderVariantRecord'; +import InMemoryFsProvider from '../../../stubs/providers/InMemory.FsProvider'; +import FsProvider from '../../../../../src/providers/generic/file/FsProvider'; +import PathResolver from '../../../../../src/r2mm/manager/PathResolver'; +import {providePathImplementation} from '../../../../../src/providers/node/path/path'; +import {TestPathProvider} from '../../../stubs/providers/node/Node.Path.Provider'; +import ManagerInformation from '../../../../../src/_managerinf/ManagerInformation'; + +const TEST_ROOT = 'TEST_ROOT'; +const latestSchemaFilePath = path.join(TEST_ROOT, 'latest-ecosystem-schema.json'); + +async function writeCacheFile(schema: Partial) { + const full: VersionedThunderstoreEcosystem = { + schemaVersion: '0.0.0', + communities: {}, + games: {}, + modloaderPackages: [], + packageInstallers: {}, + version: ManagerInformation.VERSION.toString(), + ...schema, + }; + await FsProvider.instance.writeFile(latestSchemaFilePath, JSON.stringify(full)); +} + +describe('EcosystemSchema', () => { + + beforeEach(async () => { + providePathImplementation(() => TestPathProvider); + InMemoryFsProvider.clear(); + FsProvider.provide(() => new InMemoryFsProvider()); + PathResolver.ROOT = TEST_ROOT; + EcosystemSupportedGames.value = []; + EcosystemModloaderPackages.value = []; + updateModLoaderExports(); + await FsProvider.instance.mkdirs(TEST_ROOT); + }); + + describe('updateEcosystemReactives', () => { + + test('falls back to bundled schema when no cache file exists', async () => { + expect(EcosystemSupportedGames.value).toHaveLength(0); + expect(EcosystemModloaderPackages.value).toHaveLength(0); + expect(await FsProvider.instance.exists(latestSchemaFilePath)).toBe(false); + + await updateEcosystemReactives(); + + expect(await FsProvider.instance.exists(latestSchemaFilePath)).toBe(false); + expect(EcosystemSupportedGames.value.length).toBeGreaterThan(0); + expect(EcosystemModloaderPackages.value.length).toBeGreaterThan(0); + }); + + test('loads from cache when version matches', async () => { + expect(EcosystemSupportedGames.value).toHaveLength(0); + expect(await FsProvider.instance.exists(latestSchemaFilePath)).toBe(false); + + await writeCacheFile({games: {}, modloaderPackages: []}); + await updateEcosystemReactives(); + + expect(await FsProvider.instance.exists(latestSchemaFilePath)).toBe(true); + expect(EcosystemSupportedGames.value).toHaveLength(0); + }); + + test('falls back to bundled schema when cached version is stale', async () => { + expect(EcosystemSupportedGames.value).toHaveLength(0); + await writeCacheFile({version: '0.0.0'}); + + await updateEcosystemReactives(); + + expect(EcosystemSupportedGames.value.length).toBeGreaterThan(0); + }); + + test('populates mod loader exports after updating reactives', async () => { + expect(MODLOADER_PACKAGES.length).toBe(0); + expect(Object.keys(MOD_LOADER_VARIANTS).length).toBe(0); + + await updateEcosystemReactives(); + + expect(MODLOADER_PACKAGES.length).toBeGreaterThan(0); + expect(Object.keys(MOD_LOADER_VARIANTS).length).toBeGreaterThan(0); + }); + + }); + + describe('updateLatestEcosystemSchema', () => { + + test('writes file to disk', async () => { + expect(await FsProvider.instance.exists(latestSchemaFilePath)).toBe(false); + + await updateLatestEcosystemSchema(); + + expect(await FsProvider.instance.exists(latestSchemaFilePath)).toBe(true); + }); + + test('written file contains current version', async () => { + expect(await FsProvider.instance.exists(latestSchemaFilePath)).toBe(false); + + await updateLatestEcosystemSchema(); + + const content = (await FsProvider.instance.readFile(latestSchemaFilePath)).toString('utf8'); + const parsed: VersionedThunderstoreEcosystem = JSON.parse(content); + expect(parsed.version).toBe(ManagerInformation.VERSION.toString()); + }); + + }); + +}); diff --git a/test/vitest/tests/unit/GameDirectoryResolver/GameDirectoryResolver.spec.ts b/test/vitest/tests/unit/GameDirectoryResolver/GameDirectoryResolver.spec.ts index 49e46bc81..d62a0a174 100644 --- a/test/vitest/tests/unit/GameDirectoryResolver/GameDirectoryResolver.spec.ts +++ b/test/vitest/tests/unit/GameDirectoryResolver/GameDirectoryResolver.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test } from 'vitest'; +import {beforeAll, beforeEach, describe, expect, test} from 'vitest'; import path from 'path'; import FsProvider from '../../../../../src/providers/generic/file/FsProvider'; import GameManager from '../../../../../src/model/game/GameManager'; @@ -6,16 +6,21 @@ import GameDirectoryResolverImpl from '../../../../../src/r2mm/manager/win32/Gam import InMemoryFsProvider from '../../../stubs/providers/InMemory.FsProvider'; import { providePathImplementation } from '../../../../../src/providers/node/path/path'; import { TestPathProvider } from '../../../stubs/providers/node/Node.Path.Provider'; +import {updateEcosystemReactives} from "src/r2mm/ecosystem/EcosystemSchema"; describe.skipIf(process.platform !== 'win32')('GameDirectoryResolver', () => { const steamRoot = 'TEST_STEAM_PATH'; let resolver: GameDirectoryResolverImpl; - beforeEach(async () => { - providePathImplementation(() => TestPathProvider); + beforeAll(async () => { const inMemoryFs = new InMemoryFsProvider(); FsProvider.provide(() => inMemoryFs); InMemoryFsProvider.clear(); + providePathImplementation(() => TestPathProvider); + await updateEcosystemReactives(); + }) + + beforeEach(async () => { resolver = new GameDirectoryResolverImpl(); await FsProvider.instance.mkdirs(path.join(steamRoot, 'steamapps', 'common')); }); @@ -55,4 +60,4 @@ describe.skipIf(process.platform !== 'win32')('GameDirectoryResolver', () => { expect(result).toBe(path.join(steamRoot, 'steamapps', 'common', 'Very Very Valet', 'windows')); }); -}); \ No newline at end of file +}); diff --git a/test/vitest/tests/unit/MelonLoader/state.spec.ts b/test/vitest/tests/unit/MelonLoader/state.spec.ts index 095ff6c06..75c8edec0 100644 --- a/test/vitest/tests/unit/MelonLoader/state.spec.ts +++ b/test/vitest/tests/unit/MelonLoader/state.spec.ts @@ -13,10 +13,12 @@ import GenericProfileInstaller from '../../../../../src/r2mm/installing/profile_ import GameManager from '../../../../../src/model/game/GameManager'; import ConflictManagementProvider from '../../../../../src/providers/generic/installing/ConflictManagementProvider'; import { addToStateFile } from '../../../../../src/installers/InstallRulePluginInstaller'; -import { describe, beforeEach, afterEach, test, expect } from 'vitest'; +import {describe, beforeEach, afterEach, test, expect, beforeAll} from 'vitest'; import {providePathImplementation} from "../../../../../src/providers/node/path/path"; import {TestPathProvider} from "../../../stubs/providers/node/Node.Path.Provider"; import StubProfileProvider from '../../../stubs/providers/stub.ProfileProvider'; +import {updateEcosystemReactives} from "src/r2mm/ecosystem/EcosystemSchema"; +import InMemoryFsProvider from "../../../stubs/providers/InMemory.FsProvider"; providePathImplementation(() => TestPathProvider); @@ -41,6 +43,13 @@ describe("State testing", () => { describe("State file", () => { + beforeAll(async () => { + const inMemoryFs = new InMemoryFsProvider(); + FsProvider.provide(() => inMemoryFs); + InMemoryFsProvider.clear(); + await updateEcosystemReactives(); + }) + beforeEach(beforeSetup); afterEach(() => { sandbox.restore(); @@ -66,7 +75,7 @@ describe("State testing", () => { profile ); - const out = fsStub.writeFile.args[0][1] as unknown as string; + const out = fsStub.writeFile.args[0]![1] as unknown as string; const parsed = yaml.parse(out) as ModFileTracker; expect(parsed.modName).toBe(fakeMod.getName()); // JSON serialization for speed comparison @@ -135,7 +144,7 @@ describe("State testing", () => { const outputState = await processConflictManagement(modA, modFileTrackerA, modB, modFileTrackerB); - expect(outputState.currentState.join(",")).toBe([[modFileTrackerA.files[0][1], modA.getName()], [modFileTrackerB.files[0][1], modB.getName()]].join(",")); + expect(outputState.currentState.join(",")).toBe([[modFileTrackerA.files[0]![1], modA.getName()], [modFileTrackerB.files[0]![1], modB.getName()]].join(",")); }); @@ -159,7 +168,7 @@ describe("State testing", () => { const outputState = await processConflictManagement(modA, modFileTrackerA, modB, modFileTrackerB); - expect(outputState.currentState.join(",")).toBe([[modFileTrackerB.files[0][1], modB.getName()]].join(",")); + expect(outputState.currentState.join(",")).toBe([[modFileTrackerB.files[0]![1], modB.getName()]].join(",")); }); // If a mod is disabled, the mod files should not exist if highest priority for those files. @@ -182,7 +191,7 @@ describe("State testing", () => { const outputState = await processConflictManagement(modA, modFileTrackerA, modB, modFileTrackerB); - expect(outputState.currentState.join(",")).toBe([[`${modFileTrackerA.files[0][1]}.manager.disabled`, modA.getName()], [modFileTrackerB.files[0][1], modB.getName()]].join(",")); + expect(outputState.currentState.join(",")).toBe([[`${modFileTrackerA.files[0]![1]}.manager.disabled`, modA.getName()], [modFileTrackerB.files[0]![1], modB.getName()]].join(",")); }); // If a mod is disabled, any lower priority files should not be overwritten. @@ -205,7 +214,7 @@ describe("State testing", () => { const outputState = await processConflictManagement(modA, modFileTrackerA, modB, modFileTrackerB); - expect(outputState.currentState.join(",")).toBe([[modFileTrackerA.files[0][1], modA.getName()], [`${modFileTrackerB.files[0][1]}.manager.disabled`, modB.getName()]].join(",")); + expect(outputState.currentState.join(",")).toBe([[modFileTrackerA.files[0]![1], modA.getName()], [`${modFileTrackerB.files[0]![1]}.manager.disabled`, modB.getName()]].join(",")); }); }); @@ -247,5 +256,5 @@ let processConflictManagement = async (modA: ManifestV2, modFileTrackerA: ModFil await conflictManagement.resolveConflicts([modA, modB], profile.asImmutableProfile()); - return yaml.parse(fsStub.writeFile.args[0][1] as unknown as string) as StateTracker; + return yaml.parse(fsStub.writeFile.args[0]![1] as unknown as string) as StateTracker; } diff --git a/test/vitest/tests/unit/ModLinker/ModLinker.spec.ts b/test/vitest/tests/unit/ModLinker/ModLinker.spec.ts index c3a0f72e2..e1a9151f6 100644 --- a/test/vitest/tests/unit/ModLinker/ModLinker.spec.ts +++ b/test/vitest/tests/unit/ModLinker/ModLinker.spec.ts @@ -16,6 +16,7 @@ import { providePathImplementation } from '../../../../../src/providers/node/pat import { TestPathProvider } from '../../../stubs/providers/node/Node.Path.Provider'; import { provideAppWindowImplementation } from '../../../../../src/providers/node/app/app_window'; import { TestAppWindowProvider } from '../../../stubs/providers/node/AppWindow.Provider'; +import {updateEcosystemReactives} from "src/r2mm/ecosystem/EcosystemSchema"; class ProfileProviderImpl extends ProfileProvider { ensureProfileDirectory(directory: string, profile: string): void { @@ -36,6 +37,9 @@ describe.skipIf(process.platform !== 'win32')('ModLinker', async () => { const inMemoryFs = new InMemoryFsProvider(); FsProvider.provide(() => inMemoryFs); InMemoryFsProvider.clear(); + + await updateEcosystemReactives(); + PathResolver.MOD_ROOT = 'MODS'; await inMemoryFs.mkdirs(PathResolver.MOD_ROOT); ProfileProvider.provide(() => new ProfileProviderImpl()); diff --git a/test/vitest/tests/unit/Profile/Profile.ts.spec.ts b/test/vitest/tests/unit/Profile/Profile.ts.spec.ts index 3e305bf88..414dd9b9d 100644 --- a/test/vitest/tests/unit/Profile/Profile.ts.spec.ts +++ b/test/vitest/tests/unit/Profile/Profile.ts.spec.ts @@ -9,6 +9,7 @@ import PathResolver from '../../../../../src/r2mm/manager/PathResolver'; import { beforeAll, describe, expect, test } from 'vitest'; import { providePathImplementation } from '../../../../../src/providers/node/path/path'; import { TestPathProvider } from '../../../stubs/providers/node/Node.Path.Provider'; +import { updateEcosystemReactives } from "src/r2mm/ecosystem/EcosystemSchema"; class ProfileProviderImpl extends ProfileProvider { @@ -34,6 +35,7 @@ describe("ImmutableProfile", () => { FsProvider.provide(() => inMemoryFs); ProfileProvider.provide(() => new ProfileProviderImpl()); InMemoryFsProvider.clear(); + await updateEcosystemReactives(); }); test("Initializing ImmutableProfile doesn't affect Profile", () => { diff --git a/test/vitest/utils/InstallLogicUtils.ts b/test/vitest/utils/InstallLogicUtils.ts index b93023435..9d3dafe87 100644 --- a/test/vitest/utils/InstallLogicUtils.ts +++ b/test/vitest/utils/InstallLogicUtils.ts @@ -19,6 +19,7 @@ import PathResolver from '../../../src/r2mm/manager/PathResolver'; import {providePathImplementation} from "../../../src/providers/node/path/path"; import {TestPathProvider} from "../stubs/providers/node/Node.Path.Provider"; import {expect} from 'vitest'; +import {updateEcosystemReactives} from "../../../src/r2mm/ecosystem/EcosystemSchema"; class ProfileProviderImpl extends ProfileProvider { ensureProfileDirectory(directory: string, profile: string): void { @@ -36,6 +37,7 @@ export async function installLogicBeforeEach(internalFolderName: string) { providePathImplementation(() => TestPathProvider); const inMemoryFs = new InMemoryFsProvider(); FsProvider.provide(() => inMemoryFs); + await updateEcosystemReactives(); InMemoryFsProvider.clear(); PathResolver.MOD_ROOT = 'MODS'; await inMemoryFs.mkdirs(PathResolver.MOD_ROOT);