From 9f759f9d84b70d9c4a6c064a4ac6286266ba8689 Mon Sep 17 00:00:00 2001 From: IDCs Date: Fri, 27 Mar 2026 14:24:21 +0000 Subject: [PATCH] extracted `verify` and `verifyElement` into separate file + tests closes https://linear.app/nexus-mods/issue/APP-97/extract-state-verifier-to-enable-vitest-tests --- src/renderer/src/reducers/index.ts | 115 +------- src/renderer/src/reducers/verify.test.ts | 333 +++++++++++++++++++++++ src/renderer/src/reducers/verify.ts | 126 +++++++++ 3 files changed, 463 insertions(+), 111 deletions(-) create mode 100644 src/renderer/src/reducers/verify.test.ts create mode 100644 src/renderer/src/reducers/verify.ts diff --git a/src/renderer/src/reducers/index.ts b/src/renderer/src/reducers/index.ts index 2f77ff334..5dd3d5f0d 100644 --- a/src/renderer/src/reducers/index.ts +++ b/src/renderer/src/reducers/index.ts @@ -5,7 +5,6 @@ import type { Reducer, ReducersMapObject } from "redux"; import { unknownToError } from "@vortex/shared"; -import update from "immutability-helper"; import { pick } from "lodash"; import * as path from "path"; import { combineReducers } from "redux"; @@ -17,8 +16,9 @@ import type { IReducerSpec, IStateVerifier } from "../types/IExtensionContext"; import type { IState } from "../types/IState"; import { log } from "../logging"; -import { VerifierDrop, VerifierDropParent } from "../types/IExtensionContext"; import { UserCanceled } from "../util/CustomErrors"; +import { verify } from "./verify"; +export { verify, verifyElement } from "./verify"; import deepMerge from "../util/deepMerge"; import * as fs from "../util/fs"; import getVortexPath from "../util/getVortexPath"; @@ -63,115 +63,6 @@ function safeCombineReducers( }; } -function verifyElement(verifier: IStateVerifier, value: any) { - if ( - verifier.type !== undefined && - (verifier.required || value !== undefined) && - ((verifier.type === "array" && !Array.isArray(value)) || - (verifier.type !== "array" && typeof value !== verifier.type)) - ) { - return false; - } - if (verifier.noUndefined === true && value === undefined) { - return false; - } - if (verifier.noNull === true && value === null) { - return false; - } - if (verifier.noEmpty === true) { - if (verifier.type === "array" && value.length === 0) { - return false; - } else if (verifier.type === "object" && Object.keys(value).length === 0) { - return false; - } else if (verifier.type === "string" && value.length === 0) { - return false; - } - } - return true; -} - -// exported for the purpose of testing -export function verify( - statePath: string, - verifiers: { [key: string]: IStateVerifier } | undefined, - input: any, - defaults: { [key: string]: any }, - emitDescription: (description: string) => void, -): any { - if (input === undefined || verifiers === undefined) { - return input; - } - let res = input; - - const recurse = (key: string, mapKey: string) => { - const sane = verify( - statePath, - verifiers[key].elements, - res[mapKey], - {}, - emitDescription, - ); - if (sane !== res[mapKey]) { - res = - sane === undefined - ? deleteOrNop(res, [mapKey]) - : update(res, { [mapKey]: { $set: sane } }); - } - }; - - const doTest = (key: string, realKey: string) => { - if ( - (verifiers[key].required || input.hasOwnProperty(realKey)) && - !verifyElement(verifiers[key], input[realKey]) - ) { - log("warn", "invalid state", { - statePath, - input, - key: realKey, - ver: verifiers[key], - }); - emitDescription(verifiers[key].description(input)); - if (verifiers[key].deleteBroken !== undefined) { - res = - verifiers[key].deleteBroken === "parent" - ? undefined - : deleteOrNop(res, [realKey]); - } else if (verifiers[key].repair !== undefined) { - try { - const fixed = verifiers[key].repair( - input[realKey], - defaults[realKey], - ); - res = update(res, { [realKey]: { $set: fixed } }); - } catch (err) { - if (err instanceof VerifierDrop) { - res = deleteOrNop(res, [realKey]); - } else if (err instanceof VerifierDropParent) { - res = undefined; - } - } - } else { - res = update(res, { [realKey]: { $set: defaults[realKey] } }); - } - } else if (verifiers[key].elements !== undefined) { - recurse(key, realKey); - } - }; - - Object.keys(verifiers).forEach((key) => { - if (res === undefined) { - return; - } - // _ is placeholder for every item - if (key === "_") { - Object.keys(res).forEach((mapKey) => doTest(key, mapKey)); - } else { - doTest(key, key); - } - }); - return res; -} - export enum Decision { SANITIZE, IGNORE, @@ -256,6 +147,7 @@ function hydrateRed( ++moreCount; } }, + log, ); if (sanitized !== input) { if (moreCount > 0) { @@ -456,6 +348,7 @@ export async function sanitizeHydrationState( ++moreCount; } }, + log, ); if (sanitized !== input) { if (moreCount > 0) { diff --git a/src/renderer/src/reducers/verify.test.ts b/src/renderer/src/reducers/verify.test.ts new file mode 100644 index 000000000..aef0e2ad1 --- /dev/null +++ b/src/renderer/src/reducers/verify.test.ts @@ -0,0 +1,333 @@ +import { describe, it, expect, vi } from "vitest"; + +import type { IStateVerifier } from "../types/IExtensionContext"; + +import { VerifierDrop, VerifierDropParent } from "../types/IExtensionContext"; +import { verify, verifyElement } from "./verify"; + +const desc = (msg: string) => () => msg; +const emitSpy = () => vi.fn<(d: string) => void>(); + +describe("verifyElement", () => { + describe("type checking", () => { + it("accepts a value matching the declared type", () => { + expect(verifyElement({ description: desc(""), type: "string" }, "hi")).toBe(true); + expect(verifyElement({ description: desc(""), type: "number" }, 42)).toBe(true); + expect(verifyElement({ description: desc(""), type: "boolean" }, true)).toBe(true); + expect(verifyElement({ description: desc(""), type: "array" }, [1])).toBe(true); + expect(verifyElement({ description: desc(""), type: "object" }, { a: 1 })).toBe(true); + }); + + it("rejects a value that does not match the declared type", () => { + expect(verifyElement({ description: desc(""), type: "string" }, 123)).toBe(false); + expect(verifyElement({ description: desc(""), type: "number" }, "hi")).toBe(false); + expect(verifyElement({ description: desc(""), type: "array" }, "not array")).toBe(false); + expect(verifyElement({ description: desc(""), type: "object" }, 42)).toBe(false); + }); + + it("skips type check when value is undefined and not required", () => { + expect(verifyElement({ description: desc(""), type: "string" }, undefined)).toBe(true); + }); + + it("fails type check when value is undefined but required", () => { + expect( + verifyElement({ description: desc(""), type: "string", required: true }, undefined), + ).toBe(false); + }); + }); + + describe("noUndefined", () => { + it("rejects undefined", () => { + expect(verifyElement({ description: desc(""), noUndefined: true }, undefined)).toBe(false); + }); + + it("accepts a defined value", () => { + expect(verifyElement({ description: desc(""), noUndefined: true }, "ok")).toBe(true); + }); + }); + + describe("noNull", () => { + it("rejects null", () => { + expect(verifyElement({ description: desc(""), noNull: true }, null)).toBe(false); + }); + + it("accepts non-null", () => { + expect(verifyElement({ description: desc(""), noNull: true }, 0)).toBe(true); + }); + }); + + describe("noEmpty", () => { + it("rejects empty array", () => { + expect( + verifyElement({ description: desc(""), type: "array", noEmpty: true }, []), + ).toBe(false); + }); + + it("rejects empty object", () => { + expect( + verifyElement({ description: desc(""), type: "object", noEmpty: true }, {}), + ).toBe(false); + }); + + it("rejects empty string", () => { + expect( + verifyElement({ description: desc(""), type: "string", noEmpty: true }, ""), + ).toBe(false); + }); + + it("accepts non-empty values", () => { + expect( + verifyElement({ description: desc(""), type: "array", noEmpty: true }, [1]), + ).toBe(true); + expect( + verifyElement({ description: desc(""), type: "object", noEmpty: true }, { a: 1 }), + ).toBe(true); + expect( + verifyElement({ description: desc(""), type: "string", noEmpty: true }, "x"), + ).toBe(true); + }); + }); + + it("passes when no constraints are set", () => { + expect(verifyElement({ description: desc("") }, "anything")).toBe(true); + expect(verifyElement({ description: desc("") }, undefined)).toBe(true); + expect(verifyElement({ description: desc("") }, null)).toBe(true); + }); +}); + +describe("verify", () => { + it("returns input unchanged when verifiers is undefined", () => { + const input = { foo: "bar" }; + expect(verify("test", undefined, input, {}, emitSpy())).toBe(input); + }); + + it("returns undefined when input is undefined", () => { + const verifiers: Record = { + foo: { description: desc("bad"), type: "string" }, + }; + expect(verify("test", verifiers, undefined, {}, emitSpy())).toBeUndefined(); + }); + + it("returns input by reference when everything is valid", () => { + const input = { name: "hello" }; + const verifiers: Record = { + name: { description: desc("bad"), type: "string" }, + }; + expect(verify("test", verifiers, input, {}, emitSpy())).toBe(input); + }); + + it("replaces invalid value with default when no repair or deleteBroken", () => { + const input = { count: "not a number" }; + const verifiers: Record = { + count: { description: desc("bad count"), type: "number" }, + }; + const defaults = { count: 0 }; + const emit = emitSpy(); + + const result = verify("test", verifiers, input, defaults, emit); + + expect(result).toEqual({ count: 0 }); + expect(emit).toHaveBeenCalledWith("bad count"); + }); + + it("deletes the broken key when deleteBroken is true", () => { + const input = { a: "ok", b: 123 }; + const verifiers: Record = { + b: { description: desc("bad b"), type: "string", deleteBroken: true }, + }; + + const result = verify("test", verifiers, input, {}, emitSpy()); + + expect(result).toEqual({ a: "ok" }); + expect(result).not.toHaveProperty("b"); + }); + + it("returns undefined when deleteBroken is 'parent'", () => { + const input = { a: "ok", b: 123 }; + const verifiers: Record = { + b: { description: desc("bad b"), type: "string", deleteBroken: "parent" }, + }; + + const result = verify("test", verifiers, input, {}, emitSpy()); + + expect(result).toBeUndefined(); + }); + + it("uses repair function to fix a broken value", () => { + const input = { game: "skyrim" }; + const verifiers: Record = { + game: { + description: desc("game should be array"), + type: "array", + repair: (val) => [val], + }, + }; + + const result = verify("test", verifiers, input, {}, emitSpy()); + + expect(result).toEqual({ game: ["skyrim"] }); + }); + + it("deletes key when repair throws VerifierDrop", () => { + const input = { broken: "x" }; + const verifiers: Record = { + broken: { + description: desc("bad"), + type: "number", + repair: () => { + throw new VerifierDrop(); + }, + }, + }; + + const result = verify("test", verifiers, input, {}, emitSpy()); + + expect(result).toEqual({}); + }); + + it("returns undefined when repair throws VerifierDropParent", () => { + const input = { broken: "x" }; + const verifiers: Record = { + broken: { + description: desc("bad"), + type: "number", + repair: () => { + throw new VerifierDropParent(); + }, + }, + }; + + const result = verify("test", verifiers, input, {}, emitSpy()); + + expect(result).toBeUndefined(); + }); + + it("applies wildcard verifier to every key in the object", () => { + const input = { + dl1: { game: ["skyrim"] }, + dl2: { game: "fallout4" }, + dl3: { game: ["oblivion"] }, + }; + const verifiers: Record = { + _: { + description: desc("bad download"), + elements: { + game: { + description: desc("game should be array"), + type: "array", + repair: (val) => (val !== undefined ? [val] : val), + }, + }, + }, + }; + + const result = verify("test", verifiers, input, {}, emitSpy()); + + expect(result.dl1.game).toEqual(["skyrim"]); + expect(result.dl2.game).toEqual(["fallout4"]); + expect(result.dl3.game).toEqual(["oblivion"]); + }); + + it("recurses into nested elements", () => { + const input = { + settings: { theme: 42 }, + }; + const verifiers: Record = { + settings: { + description: desc("bad settings"), + elements: { + theme: { description: desc("bad theme"), type: "string" }, + }, + }, + }; + const defaults = {}; + + const result = verify("test", verifiers, input, defaults, emitSpy()); + + expect(result.settings.theme).toBeUndefined(); + }); + + it("removes a nested element via wildcard + VerifierDropParent", () => { + const input = { + dl1: { game: ["skyrim"] }, + dl2: { game: undefined }, + }; + const verifiers: Record = { + _: { + description: desc("bad download"), + elements: { + game: { + description: desc("game required"), + type: "array", + required: true, + repair: (val) => { + if (val !== undefined) { + return [val]; + } + throw new VerifierDropParent(); + }, + }, + }, + }, + }; + + const result = verify("test", verifiers, input, {}, emitSpy()); + + expect(result.dl1).toEqual({ game: ["skyrim"] }); + expect(result).not.toHaveProperty("dl2"); + }); + + it("emits descriptions for each broken field", () => { + const input = { a: "wrong", b: "also wrong" }; + const verifiers: Record = { + a: { description: desc("a is bad"), type: "number" }, + b: { description: desc("b is bad"), type: "number" }, + }; + const emit = emitSpy(); + + verify("test", verifiers, input, { a: 0, b: 0 }, emit); + + expect(emit).toHaveBeenCalledTimes(2); + expect(emit).toHaveBeenCalledWith("a is bad"); + expect(emit).toHaveBeenCalledWith("b is bad"); + }); + + it("calls log callback for invalid state", () => { + const input = { x: "wrong" }; + const verifiers: Record = { + x: { description: desc("x bad"), type: "number" }, + }; + const logSpy = vi.fn(); + + verify("mypath", verifiers, input, { x: 0 }, emitSpy(), logSpy); + + expect(logSpy).toHaveBeenCalledWith( + "warn", + "invalid state", + expect.objectContaining({ statePath: "mypath", key: "x" }), + ); + }); + + it("replaces missing required field with default", () => { + const input = { other: "val" }; + const verifiers: Record = { + name: { description: desc("name required"), type: "string", required: true }, + }; + + const result = verify("test", verifiers, input, { name: "default" }, emitSpy()); + + expect(result.name).toBe("default"); + }); + + it("does not mutate the original input", () => { + const input = Object.freeze({ count: "bad", keep: "ok" }); + const verifiers: Record = { + count: { description: desc("bad"), type: "number" }, + }; + + const result = verify("test", verifiers, input, { count: 0 }, emitSpy()); + + expect(result).toEqual({ count: 0, keep: "ok" }); + expect(input).toEqual({ count: "bad", keep: "ok" }); + }); +}); \ No newline at end of file diff --git a/src/renderer/src/reducers/verify.ts b/src/renderer/src/reducers/verify.ts new file mode 100644 index 000000000..32a9d193f --- /dev/null +++ b/src/renderer/src/reducers/verify.ts @@ -0,0 +1,126 @@ +import update from "immutability-helper"; + +import type { IStateVerifier } from "../types/IExtensionContext"; + +import { VerifierDrop, VerifierDropParent } from "../types/IExtensionContext"; + +function deleteKey(obj: any, key: string): any { + if (obj === undefined || !Object.hasOwnProperty.call(obj, key)) { + return obj; + } + return update(obj, { $unset: [key] }); +} + +export function verifyElement(verifier: IStateVerifier, value: any) { + if ( + verifier.type !== undefined && + (verifier.required || value !== undefined) && + ((verifier.type === "array" && !Array.isArray(value)) || + (verifier.type !== "array" && typeof value !== verifier.type)) + ) { + return false; + } + if (verifier.noUndefined === true && value === undefined) { + return false; + } + if (verifier.noNull === true && value === null) { + return false; + } + if (verifier.noEmpty === true) { + if (verifier.type === "array" && value.length === 0) { + return false; + } else if (verifier.type === "object" && Object.keys(value).length === 0) { + return false; + } else if (verifier.type === "string" && value.length === 0) { + return false; + } + } + return true; +} + +export type LogFn = (level: string, message: string, metadata?: any) => void; + +const noop: LogFn = () => {}; + +export function verify( + statePath: string, + verifiers: { [key: string]: IStateVerifier } | undefined, + input: any, + defaults: { [key: string]: any }, + emitDescription: (description: string) => void, + log: LogFn = noop, +): any { + if (input === undefined || verifiers === undefined) { + return input; + } + let res = input; + + const recurse = (key: string, mapKey: string) => { + const sane = verify( + statePath, + verifiers[key].elements, + res[mapKey], + {}, + emitDescription, + log, + ); + if (sane !== res[mapKey]) { + res = + sane === undefined + ? deleteKey(res, mapKey) + : update(res, { [mapKey]: { $set: sane } }); + } + }; + + const doTest = (key: string, realKey: string) => { + if ( + (verifiers[key].required || input.hasOwnProperty(realKey)) && + !verifyElement(verifiers[key], input[realKey]) + ) { + log("warn", "invalid state", { + statePath, + input, + key: realKey, + ver: verifiers[key], + }); + emitDescription(verifiers[key].description(input)); + if (verifiers[key].deleteBroken !== undefined) { + res = + verifiers[key].deleteBroken === "parent" + ? undefined + : deleteKey(res, realKey); + } else if (verifiers[key].repair !== undefined) { + try { + const fixed = verifiers[key].repair( + input[realKey], + defaults[realKey], + ); + res = update(res, { [realKey]: { $set: fixed } }); + } catch (err) { + if (err instanceof VerifierDrop) { + res = deleteKey(res, realKey); + } else if (err instanceof VerifierDropParent) { + res = undefined; + } + } + } else { + res = update(res, { [realKey]: { $set: defaults[realKey] } }); + } + } else if (verifiers[key].elements !== undefined) { + recurse(key, realKey); + } + }; + + Object.keys(verifiers).forEach((key) => { + if (res === undefined) { + return; + } + // _ is placeholder for every item + if (key === "_") { + Object.keys(res).forEach((mapKey) => doTest(key, mapKey)); + } else { + doTest(key, key); + } + }); + return res; +}