Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "riftlauncher",
"version": "1.7.0-beta.7",
"version": "1.7.0-beta.8",
"description": "RiftLauncher for Vintage Story",
"overrides": {
"js-yaml": "4.3.1",
Expand Down
38 changes: 32 additions & 6 deletions src/domain/mods/installedFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,32 @@ export const NO_INSTALLED_MOD_FILTERS: InstalledModFilters = { author: "", tags:
/** The shape a ModDB game-version release tag has: a dotted number, never a leading "v". */
const GAME_VERSION_TAG = /^\d+(\.\d+)+/

/**
* Keeps only the string entries of a list handed to us by the mod database or a modinfo.
*
* The API's documented shape says `tags` is a list of strings. The live detail endpoint returns
* `["Cosmetics", "Crafting", "Storage", null]` for Vanilla Variants (#370). Every reader below folds
* case or tests a prefix, both of which throw on `null`, and one throw during the first render blanks
* the whole page. So nothing in this module trusts the shape: whatever is not a string is dropped, and
* a field that is not a list at all reads as empty.
*/
function textEntries(values: unknown): string[] {
return Array.isArray(values) ? values.filter((value): value is string => typeof value === "string") : []
}

/**
* The releases the mod database lists for a mod, keeping only the entries that are objects.
*
* Guarding the outer field is not enough: `{ releases: [null] }` passes an array check and then
* `release.tags` throws on the entry, the same render crash this module exists to rule out. So each
* entry is checked too, and a missing or malformed field reads as no releases at all.
*/
function releasesOf(mod: InstalledModType): readonly { tags?: unknown }[] {
const releases: unknown = mod._mod?.releases
if (!Array.isArray(releases)) return []
return releases.filter((release): release is { tags?: unknown } => typeof release === "object" && release !== null)
}

/**
* Deduplicates ignoring case, keeping the first spelling seen.
*
Expand All @@ -42,7 +68,7 @@ function uniqueIgnoringCase(values: readonly string[]): string[] {

/** Every author across the scan, A to Z. Read from the local modinfo, so this axis needs no network. */
export function installedModAuthors(mods: readonly InstalledModType[]): string[] {
return uniqueIgnoringCase(mods.flatMap((mod) => mod.authors ?? [])).sort((a, b) => a.localeCompare(b))
return uniqueIgnoringCase(mods.flatMap((mod) => textEntries(mod.authors))).sort((a, b) => a.localeCompare(b))
}

/**
Expand All @@ -52,7 +78,7 @@ export function installedModAuthors(mods: readonly InstalledModType[]): string[]
* modinfo.ts shows, so this list is empty for a folder the ModDB cannot answer for.
*/
export function installedModTags(mods: readonly InstalledModType[]): string[] {
return uniqueIgnoringCase(mods.flatMap((mod) => mod._mod?.tags ?? [])).sort((a, b) => a.localeCompare(b))
return uniqueIgnoringCase(mods.flatMap((mod) => textEntries(mod._mod?.tags))).sort((a, b) => a.localeCompare(b))
}

/**
Expand All @@ -63,21 +89,21 @@ export function installedModTags(mods: readonly InstalledModType[]): string[] {
* are the category tags the other dropdown offers.
*/
export function installedModGameVersions(mods: readonly InstalledModType[]): string[] {
const tags = mods.flatMap((mod) => (mod._mod?.releases ?? []).flatMap((release) => release.tags ?? []))
const tags = mods.flatMap((mod) => releasesOf(mod).flatMap((release) => textEntries(release.tags)))
return Array.from(new Set(tags.filter((tag) => GAME_VERSION_TAG.test(tag)))).sort((a, b) => compareVersions(b, a))
}

/** Ignores case, and counts every credited author rather than only the first one. */
function matchesAuthor(mod: InstalledModType, author: string): boolean {
if (author === "") return true
const wanted = author.toLowerCase()
return (mod.authors ?? []).some((name) => name.toLowerCase() === wanted)
return textEntries(mod.authors).some((name) => name.toLowerCase() === wanted)
}

/** Every selected tag has to be present, so picking a second tag narrows the list rather than widening it. */
function matchesTags(mod: InstalledModType, tags: readonly string[]): boolean {
if (tags.length < 1) return true
const modTags = (mod._mod?.tags ?? []).map((tag) => tag.toLowerCase())
const modTags = textEntries(mod._mod?.tags).map((tag) => tag.toLowerCase())
return tags.every((tag) => modTags.includes(tag.toLowerCase()))
}

Expand All @@ -91,7 +117,7 @@ function matchesTags(mod: InstalledModType, tags: readonly string[]): boolean {
*/
function matchesGameVersion(mod: InstalledModType, gameVersion: string): boolean {
if (gameVersion === "") return true
return (mod._mod?.releases ?? []).some((release) => evaluateModCompatibility(release.tags ?? [], gameVersion) !== "undeclared")
return releasesOf(mod).some((release) => evaluateModCompatibility(textEntries(release.tags), gameVersion) !== "undeclared")
}

/** All three axes at once. A mod clears every one of them, so each pick narrows what is left. */
Expand Down
45 changes: 38 additions & 7 deletions src/domain/mods/moddb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,32 @@ export function parseModListResponse(rawText: string): ModDbResponse<ModDbModSum
export interface ModDbModDetail extends Record<string, unknown> {
modid: number
name: string
/** Always a list of strings, whatever the API sent: see {@link readModDetail}. */
tags: string[]
/** Always a list of objects, each with string `modversion` and string-only `tags`: see {@link readRelease}. */
releases: Record<string, unknown>[]
logofile?: string
}

/**
* Cleans one entry of a detail's `releases` array.
*
* Every reader downstream, the update scan, the release list, the modpack planner, treats a release
* as an object with a string `modversion` and a `tags` array of strings, because that is what the
* ModDB documents. It is not what the ModDB always sends: a listing has been seen with a `null` in
* the array and with `null` under `tags`, which is what took the Manage Mods page down in beta.7.
*
* `modversion` is coerced to an empty string rather than dropping the release, because
* {@link newestReleaseFileId} reads `releases[0]` to build the download URL for the newest file:
* removing an entry here would silently shift which file "install newest" picks. Every reader
* already treats a falsy `modversion` as unusable, so an empty one costs that release nothing but
* the version comparisons it could never have taken part in anyway.
*/
function readRelease(value: unknown): Record<string, unknown> | undefined {
if (!isRecord(value)) return undefined
return { ...value, tags: cleanStrings(value["tags"]), modversion: typeof value["modversion"] === "string" ? value["modversion"] : "" }
}

function readModDetail(value: unknown): ModDbModDetail | undefined {
if (!isRecord(value)) return undefined

Expand All @@ -132,9 +155,20 @@ function readModDetail(value: unknown): ModDbModDetail | undefined {
// The install popup maps `releases` directly, so a detail without the array would crash it
// instead of reaching its failure state. Unlike modid and name this is not a field the API is
// known to always send, hence the explicit check rather than trusting the shape.
if (!Array.isArray(value["releases"])) return undefined

return { ...value, modid, name, ...(typeof value["logofile"] === "string" ? { logofile: value["logofile"] } : {}) }
const releases = value["releases"]
if (!Array.isArray(releases)) return undefined

return {
...value,
modid,
name,
// Mod-level tags carry the same `null` the Vanilla Variants listing shipped in beta.7. The
// installed filters tolerate it on their own since #371, cleaning it here makes that tolerance
// a second line rather than the only one.
tags: cleanStrings(value["tags"]),
releases: releases.map(readRelease).filter((release): release is Record<string, unknown> => release !== undefined),
...(typeof value["logofile"] === "string" ? { logofile: value["logofile"] } : {})
}
}

/**
Expand All @@ -155,10 +189,7 @@ export function parseModDetailResponse(rawText: string): ModDbResponse<ModDbModD
* rather than trusted because it ends up in a URL.
*/
export function newestReleaseFileId(detail: ModDbModDetail): number | undefined {
const newest = (detail["releases"] as unknown[])[0]
if (!isRecord(newest)) return undefined

const fileId = newest["fileid"]
const fileId = detail.releases[0]?.["fileid"]
return typeof fileId === "number" && Number.isSafeInteger(fileId) && fileId > 0 ? fileId : undefined
}

Expand Down
49 changes: 49 additions & 0 deletions tests/domain/mods/installedFilters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,52 @@ describe("matching one installed mod against the filters", () => {
assert.equal(hasActiveInstalledModFilters(filters({ gameVersion: "1.20.0" })), true)
})
})

describe("real mod database shapes (#370)", () => {
// The detail endpoint returns exactly this for Vanilla Variants: a null among the category tags.
// The documented shape says strings only, and the launcher used to believe it.
const nullTag = ["Cosmetics", "Crafting", "Storage", null] as unknown as string[]
const vanillaVariants = aMod("VanillaVariants", { _mod: aDetail(nullTag, [["1.21.0", "1.21.1"]]) })

it("collects category tags past a null in the list instead of throwing", () => {
assert.deepEqual(installedModTags([vanillaVariants]), ["Cosmetics", "Crafting", "Storage"])
})

it("still matches the tags that are real when a null sits beside them", () => {
assert.equal(matchesInstalledModFilters(vanillaVariants, { ...NO_INSTALLED_MOD_FILTERS, tags: ["storage"] }), true)
assert.equal(matchesInstalledModFilters(vanillaVariants, { ...NO_INSTALLED_MOD_FILTERS, tags: ["Food"] }), false)
})

it("collects game versions past a null in a release's tags", () => {
const releaseWithNull = [["1.21.0", null, "1.21.1"]] as unknown as string[][]
const mod = aMod("Odd", { _mod: aDetail(["Other"], releaseWithNull) })
assert.deepEqual(installedModGameVersions([mod]), ["1.21.1", "1.21.0"])
assert.equal(matchesInstalledModFilters(mod, { ...NO_INSTALLED_MOD_FILTERS, gameVersion: "1.21.0" }), true)
})

it("skips a null entry inside an otherwise valid releases list", () => {
// { releases: [null, {...}] } passes an array check and used to throw on `release.tags`.
const detail = aDetail(["Other"], [["1.21.0"]])
const mod = aMod("Odd", { _mod: { ...detail, releases: [null, ...detail.releases] as unknown as typeof detail.releases } })
assert.deepEqual(installedModGameVersions([mod]), ["1.21.0"])
assert.equal(matchesInstalledModFilters(mod, { ...NO_INSTALLED_MOD_FILTERS, gameVersion: "1.21.0" }), true)
})

it("ignores an author entry that is not a string", () => {
const mod = aMod("Odd", { authors: ["Ann", null, 42, "Bob"] as unknown as string[] })
assert.deepEqual(installedModAuthors([mod]), ["Ann", "Bob"])
assert.equal(matchesInstalledModFilters(mod, { ...NO_INSTALLED_MOD_FILTERS, author: "bob" }), true)
})

it("reads a tags field that is not a list as no tags, and releases that are not a list as no releases", () => {
const detail = aDetail([], [])
// A string rather than null on purpose: `null ?? []` would read as empty by accident, and the
// guard has to hold for anything that is not a list, not only for the absent case.
const mod = aMod("Odd", { _mod: { ...detail, tags: "Cosmetics" as unknown as string[], releases: "1.21.0" as unknown as typeof detail.releases } })
assert.deepEqual(installedModTags([mod]), [])
assert.deepEqual(installedModGameVersions([mod]), [])
assert.equal(matchesInstalledModFilters(mod, { ...NO_INSTALLED_MOD_FILTERS, tags: ["cosmetics"] }), false)
assert.equal(matchesInstalledModFilters(mod, { ...NO_INSTALLED_MOD_FILTERS, gameVersion: "1.21.0" }), false)
assert.equal(filterInstalledMods([mod], NO_INSTALLED_MOD_FILTERS).length, 1)
})
})
68 changes: 67 additions & 1 deletion tests/domain/mods/moddb.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ describe("parseModDetailResponse", () => {
assert.equal(result.payload.modid, 1783)
assert.equal(result.payload.name, "Config lib")
assert.equal(result.payload.logofile, "https://moddbcdn.vintagestory.at/config.png")
assert.deepEqual(result.payload["releases"], [{ releaseid: 1, modversion: "1.12.0" }])
assert.deepEqual(result.payload["releases"], [{ releaseid: 1, modversion: "1.12.0", tags: [] }])
})

it("names a mod field that is not an object as malformed", () => {
Expand Down Expand Up @@ -142,6 +142,72 @@ describe("parseModDetailResponse", () => {
})
})

/**
* Issue #370: the ModDB served Vanilla Variants with a `null` among its tags, and the first render
* of Manage Mods died on it. The hotfix taught the installed filters to survive that. These pin the
* same shapes at the boundary instead, so that no reader downstream has to know about them.
*/
describe("parseModDetailResponse: shapes the ModDB actually sends", () => {
function detailOf(mod: unknown): ModDbModDetail {
const result = parseModDetailResponse(JSON.stringify({ statuscode: "200", mod }))
if (!result.ok) throw new Error("unreachable")
return result.payload
}

it("keeps a null out of the mod's own tags, the Vanilla Variants payload that took the page down", () => {
const detail = detailOf({ modid: 1, name: "Vanilla Variants", tags: ["Cosmetics", "Crafting", "Storage", null], releases: [] })
assert.deepEqual(detail.tags, ["Cosmetics", "Crafting", "Storage"])
})

it("reads a missing or wrongly typed mod tags field as no tags at all", () => {
assert.deepEqual(detailOf({ modid: 1, name: "No Tags", releases: [] }).tags, [])
assert.deepEqual(detailOf({ modid: 1, name: "Odd Tags", tags: "Storage", releases: [] }).tags, [])
})

it("drops a release that is not an object, which every consumer dereferences unchecked", () => {
const detail = detailOf({ modid: 1, name: "Nulled", releases: [null, { releaseid: 2, modversion: "1.1.0" }, "not a release"] })
assert.deepEqual(
detail.releases.map((release) => release["releaseid"]),
[2]
)
})

it("keeps a null out of a release's tags, which reach evaluateModCompatibility raw", () => {
const detail = detailOf({
modid: 1,
name: "Tagged",
releases: [
{ modversion: "1.1.0", tags: ["1.21.0", null] },
{ modversion: "1.0.0", tags: null }
]
})
assert.deepEqual(
detail.releases.map((release) => release["tags"]),
[["1.21.0"], []]
)
})

it("reads a release modversion that is not a string as an empty one, rather than dropping the release", () => {
// Dropping it would shift releases[0], which newestReleaseFileId turns into the download URL.
const detail = detailOf({ modid: 1, name: "Versionless", releases: [{ fileid: 42, modversion: null }] })
assert.equal(detail.releases[0]?.["modversion"], "")
assert.equal(newestReleaseFileId(detail), 42)
})

it("carries every other release field through untouched", () => {
const detail = detailOf({ modid: 1, name: "Whole", releases: [{ releaseid: 9, fileid: 42, mainfile: "https://mods.example/a.zip", modidstr: "a", changelog: "<p>hi</p>" }] })
assert.deepEqual(detail.releases[0], {
releaseid: 9,
fileid: 42,
mainfile: "https://mods.example/a.zip",
modidstr: "a",
changelog: "<p>hi</p>",
modversion: "",
tags: []
})
})
})

describe("newestReleaseFileId", () => {
function detail(releases: unknown[]): ModDbModDetail {
const result = parseModDetailResponse(JSON.stringify({ statuscode: "200", mod: { modid: 11016, name: "RiftLauncher", releases } }))
Expand Down
63 changes: 63 additions & 0 deletions tests/renderer-dom/manageMods.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1016,3 +1016,66 @@ describe("ManageMods: filtering the installed Mods", { timeout: 20000 }, () => {
expect(screen.getByRole("button", { name: "VS Version" })).toBeTruthy()
})
})

/**
* Issue #370, and Zaldaryon's review of #374 on top of it. Beta.7 shipped a blank Manage Mods page
* because the ModDB served Vanilla Variants with a `null` among its tags. The hotfix taught the
* installed filters to read around that, but `useGetCompleteInstalledMods` still walks the raw
* `releases` array first, so the page died before the filters ever ran.
*
* The payload below is deliberately raw and deliberately ugly: it goes through the real
* `parseModDetailResponse` on its way in, which is where the shape is now fixed.
*/
describe("ManageMods: a ModDB payload with nulls in it", () => {
const ALPHA_MALFORMED = JSON.stringify({
statuscode: "200",
mod: {
modid: 1,
assetid: 101,
name: "Alpha Mod",
// The Vanilla Variants tag list, verbatim.
tags: ["Cosmetics", "Crafting", "Storage", null],
releases: [
// A hole in the array: `release.modversion` on this one is what threw.
null,
{ releaseid: 14, mainfile: "https://mods.example/alpha-1.3.0.zip", filename: "alpha-1.3.0.zip", fileid: 14, tags: null, modidstr: "alpha", modversion: "1.3.0" },
{ releaseid: 13, mainfile: "https://mods.example/alpha-1.2.0.zip", filename: "alpha-1.2.0.zip", fileid: 13, tags: ["1.20.0", null], modidstr: "alpha", modversion: "1.2.0" },
{ releaseid: 12, mainfile: "https://mods.example/alpha-1.1.0.zip", filename: "alpha-1.1.0.zip", fileid: 12, tags: ["1.20.0"], modidstr: "alpha", modversion: null }
]
}
})

function queryMalformedModDb(url: string): Promise<string> {
if (url.endsWith("/mod/alpha")) return Promise.resolve(ALPHA_MALFORMED)
return queryModDb(url)
}

it("finishes loading and lists the Mod instead of taking the page down", async () => {
renderManageMods({ netManager: { queryURL: vi.fn(queryMalformedModDb) } })

// The page renders at all, which is the whole of #370.
expect(await screen.findByText("Alpha Mod", {}, { timeout: 3000 })).toBeTruthy()
expect(screen.getByText("Beta Mod")).toBeTruthy()
expect(screen.getByText("Gamma Mod")).toBeTruthy()
expect(screen.getByText("broken.zip")).toBeTruthy()
})

it("still reads the update off the releases that survived the cleaning", async () => {
const user = userEvent.setup()
renderManageMods({
netManager: { queryURL: vi.fn(queryMalformedModDb) },
modsManager: { getInstalledMods: vi.fn(async () => ({ mods: [aModScan().mods[0] as InstalledModType], errors: [] })) },
pathsManager: { deletePath: vi.fn(async () => true), downloadOnPath: vi.fn(async () => "/games/a/Mods/alpha-1.2.0.zip") }
})

await screen.findByText("Alpha Mod", {}, { timeout: 3000 })
expect(screen.getByText("Mods with updates")).toBeTruthy()

await user.click(screen.getByText("Update all").closest("button") as HTMLElement)

// 1.3.0 is newer still, but its tags came in as `null`: undeclared, so the offer is 1.2.0, the
// newest release actually tagged for this Installation once its own null tag was dropped.
const summary = await screen.findByRole("dialog", {}, { timeout: 3000 })
expect(within(summary).getByText("v1.2.0")).toBeTruthy()
})
})
Loading
Loading