diff --git a/lib/extension/bridge.ts b/lib/extension/bridge.ts index 2d4624320..36f20e3e1 100644 --- a/lib/extension/bridge.ts +++ b/lib/extension/bridge.ts @@ -2,7 +2,6 @@ import fs from "node:fs"; import path from "node:path"; import bind from "bind-decorator"; import {zip} from "fflate"; -import objectAssignDeep from "object-assign-deep"; import type winston from "winston"; import Transport from "winston-transport"; import {Zcl} from "zigbee-herdsman"; @@ -13,6 +12,7 @@ import type Group from "../model/group"; import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints} from "../types/api"; import data from "../util/data"; import logger from "../util/logger"; +import {objectAssignDeep} from "../util/objectAssignDeep"; import * as settings from "../util/settings"; import {stringify} from "../util/stringify"; import utils, {assertString, DEFAULT_BIND_GROUP_ID} from "../util/utils"; diff --git a/lib/state.ts b/lib/state.ts index e9aa9e26c..514615dcb 100644 --- a/lib/state.ts +++ b/lib/state.ts @@ -1,9 +1,8 @@ import {existsSync, readFileSync, writeFileSync} from "node:fs"; -import objectAssignDeep from "object-assign-deep"; - import data from "./util/data"; import logger from "./util/logger"; +import {objectAssignDeep} from "./util/objectAssignDeep"; import * as settings from "./util/settings"; import utils from "./util/utils"; diff --git a/lib/types/api.ts b/lib/types/api.ts index 0c5e905a4..dbc9308bd 100644 --- a/lib/types/api.ts +++ b/lib/types/api.ts @@ -59,7 +59,7 @@ export type OnboardData = OnboardInitData | OnboardDoneData | OnboardFailureData export type OnboardSubmitResponse = {success: true; frontendUrl: string | null} | {success: false; error: string}; -export interface Zigbee2MQTTDeviceOptions { +export type Zigbee2MQTTDeviceOptions = { disabled?: boolean; retention?: number; availability?: @@ -83,9 +83,9 @@ export interface Zigbee2MQTTDeviceOptions { description?: string; qos?: 0 | 1 | 2; disable_automatic_update_check?: boolean; -} +}; -export interface Zigbee2MQTTGroupOptions { +export type Zigbee2MQTTGroupOptions = { ID: number; optimistic?: boolean; off_state?: "all_members_off" | "last_member_state"; @@ -96,9 +96,9 @@ export interface Zigbee2MQTTGroupOptions { friendly_name: string; description?: string; qos?: 0 | 1 | 2; -} +}; -export interface Zigbee2MQTTSettings { +export type Zigbee2MQTTSettings = { version?: number; /** only used internally during startup, removed on successful Z2M start */ onboarding?: true; @@ -224,7 +224,7 @@ export interface Zigbee2MQTTSettings { interval: number; reset_on_check: boolean; }; -} +}; export interface Zigbee2MQTTScene { id: number; diff --git a/lib/types/types.d.ts b/lib/types/types.d.ts index cc09c32d3..7aff8c38a 100644 --- a/lib/types/types.d.ts +++ b/lib/types/types.d.ts @@ -26,6 +26,9 @@ declare global { type PublishEntityState = (entity: Device | Group, payload: KeyValue, stateChangeReason?: StateChangeReason) => Promise; type RecursivePartial = {[P in keyof T]?: RecursivePartial}; type MakePartialExcept = Partial> & Pick; + /** Convert `A | B | C` into `A & B & C` */ + // biome-ignore lint/suspicious/noExplicitAny: distributive conditional requires `any` + type UnionToIntersection = (U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never; interface KeyValue { // biome-ignore lint/suspicious/noExplicitAny: API [s: string]: any; diff --git a/lib/util/objectAssignDeep.ts b/lib/util/objectAssignDeep.ts new file mode 100644 index 000000000..a6da71862 --- /dev/null +++ b/lib/util/objectAssignDeep.ts @@ -0,0 +1,67 @@ +/** Anything mergeable: a plain-ish object, explicitly not an array or another iterable. */ +export type UnknownRecord = Record & {[Symbol.iterator]?: never}; + +function isUnknownRecord(value: unknown): value is UnknownRecord { + return value != null && typeof value === "object" && !Array.isArray(value); +} + +function cloneArray(input: readonly unknown[]): unknown[] { + const len = input.length; + const output: unknown[] = new Array(len); + + for (let i = 0; i < len; i++) { + const val = input[i]; + output[i] = isUnknownRecord(val) ? cloneObject(val) : Array.isArray(val) ? cloneArray(val) : val; + } + + return output; +} + +function cloneObject(input: UnknownRecord): UnknownRecord { + const output: UnknownRecord = {}; + + for (const key of Object.keys(input)) { + if (key !== "__proto__" && key !== "constructor" && key !== "prototype") { + const val = input[key]; + output[key] = isUnknownRecord(val) ? cloneObject(val) : Array.isArray(val) ? cloneArray(val) : val; + } + } + + return output; +} + +/** + * Merge all sources into `target` recursively. + * + * Key behavior: + * - ignore properties `__proto__`, `constructor` & `prototype` + * - assumes no infinite circular possible (unhandled for perf) + * + * Pass empty object `{}` as `target` to return a new object without modifying any existing objects. + */ +export function objectAssignDeep( + target: T, + ...sources: S +): T & UnionToIntersection { + for (const source of sources) { + for (const key of Object.keys(source)) { + if (key === "__proto__" || key === "constructor" || key === "prototype") { + continue; + } + + const value = source[key]; + + if (isUnknownRecord(value)) { + const existing = target[key]; + + (target as UnknownRecord)[key] = isUnknownRecord(existing) ? objectAssignDeep({}, existing, value) : cloneObject(value); + } else if (Array.isArray(value)) { + (target as UnknownRecord)[key] = cloneArray(value); + } else { + (target as UnknownRecord)[key] = value; + } + } + } + + return target as T & UnionToIntersection; +} diff --git a/lib/util/settings.ts b/lib/util/settings.ts index 38dc2861a..66fe1781c 100644 --- a/lib/util/settings.ts +++ b/lib/util/settings.ts @@ -1,8 +1,8 @@ import path from "node:path"; import type {ValidateFunction} from "ajv"; import Ajv from "ajv"; -import objectAssignDeep from "object-assign-deep"; import data from "./data"; +import {objectAssignDeep} from "./objectAssignDeep"; import schemaJson from "./settings.schema.json"; import utils from "./utils"; import yaml from "./yaml"; @@ -231,7 +231,10 @@ export function write(): void { const writeDevicesOrGroups = (type: "devices" | "groups"): void => { if (typeof actual[type] === "string" || (Array.isArray(actual[type]) && actual[type].length > 0)) { const fileToWrite = Array.isArray(actual[type]) ? actual[type][0] : actual[type]; - const content = objectAssignDeep({}, settings[type]); + // `readDevicesOrGroups()` already set this to an object whenever the config points at separate files, but the + // persisted settings are `Partial`, so the fallback is only here to satisfy the type + /* v8 ignore next */ + const content = objectAssignDeep({}, settings[type] ?? {}); // If an array, only write to first file and only devices which are not in the other files. if (Array.isArray(actual[type])) { @@ -368,8 +371,7 @@ function read(): Partial { s[type] = {}; for (const file of files) { const content = yaml.readIfExists(data.joinPath(file)); - // @ts-expect-error noMutate not typed properly - s[type] = objectAssignDeep.noMutate(s[type], content); + s[type] = objectAssignDeep({}, s[type], content); } } }; @@ -479,9 +481,7 @@ export function set(path: string[], value: string | number | boolean | KeyValue) } export function apply(settings: Record, throwOnError = true): boolean { - getPersistedSettings(); // Ensure _settings is initialized. - // @ts-expect-error noMutate not typed properly - const newSettings = objectAssignDeep.noMutate(_settings, settings); + const newSettings = objectAssignDeep({}, getPersistedSettings(), settings); utils.removeNullPropertiesFromObject(newSettings, NULLABLE_SETTINGS); diff --git a/package.json b/package.json index b426ed16a..b352f30d5 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,6 @@ "humanize-duration": "^3.34.0", "js-yaml": "^5.2.2", "mqtt": "^5.15.2", - "object-assign-deep": "^0.4.0", "semver": "^7.8.5", "throttleit": "^3.0.0", "winston": "^3.19.0", @@ -69,7 +68,6 @@ "@types/finalhandler": "^1.2.3", "@types/humanize-duration": "^3.27.4", "@types/node": "^26.1.2", - "@types/object-assign-deep": "^0.4.3", "@types/readable-stream": "4.0.24", "@types/serve-static": "^2.2.0", "@types/ws": "8.18.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21cb49f2d..dee8ce8a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,9 +41,6 @@ importers: mqtt: specifier: ^5.15.2 version: 5.15.2 - object-assign-deep: - specifier: ^0.4.0 - version: 0.4.0 semver: specifier: ^7.8.5 version: 7.8.5 @@ -87,9 +84,6 @@ importers: '@types/node': specifier: ^26.1.2 version: 26.1.2 - '@types/object-assign-deep': - specifier: ^0.4.3 - version: 0.4.3 '@types/readable-stream': specifier: 4.0.24 version: 4.0.24 @@ -537,9 +531,6 @@ packages: '@types/node@26.1.2': resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} - '@types/object-assign-deep@0.4.3': - resolution: {integrity: sha512-d9Gxaj5j1hzrxJ61EFEg13B4g4FgrT/DYtcDWFXPehR8DF2SUZbVMFtZIs8exkVRiqrqBpdTc/lUUZjncsPpMw==} - '@types/readable-stream@4.0.24': resolution: {integrity: sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==} @@ -1114,10 +1105,6 @@ packages: number-allocator@1.0.14: resolution: {integrity: sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==} - object-assign-deep@0.4.0: - resolution: {integrity: sha512-54Uvn3s+4A/cMWx9tlRez1qtc7pN7pbQ+Yi7mjLjcBpWLlP+XbSHiHbQW6CElDiV4OvuzqnMrBdkgxI1mT8V/Q==} - engines: {node: '>=6'} - on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -1796,8 +1783,6 @@ snapshots: dependencies: undici-types: 8.3.0 - '@types/object-assign-deep@0.4.3': {} - '@types/readable-stream@4.0.24': dependencies: '@types/node': 26.1.2 @@ -2358,8 +2343,6 @@ snapshots: transitivePeerDependencies: - supports-color - object-assign-deep@0.4.0: {} - on-finished@2.4.1: dependencies: ee-first: 1.1.1 diff --git a/test/objectAssignDeep.test.ts b/test/objectAssignDeep.test.ts new file mode 100644 index 000000000..064932eec --- /dev/null +++ b/test/objectAssignDeep.test.ts @@ -0,0 +1,161 @@ +import {describe, expect, it} from "vitest"; +import {objectAssignDeep} from "../lib/util/objectAssignDeep"; + +/** Creates an object with a real own `__proto__`/`constructor`/`prototype` property, like a parsed YAML/JSON payload can. */ +const parse = (json: string): Record => JSON.parse(json); + +describe("objectAssignDeep", () => { + it("Mutates and returns the target", () => { + const target = {a: 1}; + const result = objectAssignDeep(target, {b: 2}); + + expect(result).toBe(target); + expect(result).toStrictEqual({a: 1, b: 2}); + }); + + it("Applies sources in order, later ones win", () => { + expect(objectAssignDeep({}, {a: 1, b: 1}, {b: 2, c: 2})).toStrictEqual({a: 1, b: 2, c: 2}); + }); + + it("Copies keys missing from the target", () => { + expect(objectAssignDeep({}, {nested: {deep: {value: 1}}})).toStrictEqual({nested: {deep: {value: 1}}}); + }); + + it("Deep merges nested objects present in both", () => { + const target = {mqtt: {base_topic: "zigbee2mqtt", server: "old"}, advanced: {channel: 11}}; + const result = objectAssignDeep(target, {mqtt: {server: "new"}}); + + expect(result).toStrictEqual({mqtt: {base_topic: "zigbee2mqtt", server: "new"}, advanced: {channel: 11}}); + }); + + it("Replaces nested objects of the target instead of mutating them", () => { + const nested = {a: 1}; + const target = {nested}; + + objectAssignDeep(target, {nested: {b: 2}}); + + expect(nested).toStrictEqual({a: 1}); + expect(target.nested).not.toBe(nested); + expect(target.nested).toStrictEqual({a: 1, b: 2}); + }); + + it("Replaces an existing non-object value with a clone of the source object", () => { + expect(objectAssignDeep({a: 5}, {a: {b: 1}})).toStrictEqual({a: {b: 1}}); + expect(objectAssignDeep({a: "str"}, {a: {b: 1}})).toStrictEqual({a: {b: 1}}); + expect(objectAssignDeep({a: [1, 2]}, {a: {b: 1}})).toStrictEqual({a: {b: 1}}); + // `null` is not `undefined`, so it takes the "existing value" path but is not merged into + expect(objectAssignDeep({a: null}, {a: {b: 1}})).toStrictEqual({a: {b: 1}}); + }); + + it("Overwrites with null and undefined", () => { + expect(objectAssignDeep({a: {b: 1}, c: 1}, {a: null, c: null})).toStrictEqual({a: null, c: null}); + expect(objectAssignDeep({a: {b: 1}, c: 1}, {a: undefined, c: undefined})).toStrictEqual({a: undefined, c: undefined}); + }); + + it("Replaces arrays instead of concatenating them", () => { + expect(objectAssignDeep({a: [1, 2, 3]}, {a: [4]})).toStrictEqual({a: [4]}); + expect(objectAssignDeep({a: [1, 2, 3]}, {a: []})).toStrictEqual({a: []}); + // no existing array either + expect(objectAssignDeep({a: 1}, {a: [4]})).toStrictEqual({a: [4]}); + expect(objectAssignDeep({}, {a: [4]})).toStrictEqual({a: [4]}); + }); + + it("Clones arrays and the objects nested inside them", () => { + const source = {a: [{b: 1}, [{c: 2}]]}; + const result = objectAssignDeep({}, source) as typeof source; + + expect(result).toStrictEqual(source); + expect(result.a).not.toBe(source.a); + expect(result.a[0]).not.toBe(source.a[0]); + expect((result.a[1] as {c: number}[])[0]).not.toBe((source.a[1] as {c: number}[])[0]); + }); + + it("Breaks all references to the sources", () => { + const source = {a: {b: {c: 1}}}; + const result = objectAssignDeep({}, source) as typeof source; + + source.a.b.c = 99; + + expect(result.a.b.c).toStrictEqual(1); + }); + + it("Does not mutate the sources", () => { + const source = {a: {b: 1}}; + + objectAssignDeep({a: {c: 2}}, source); + + expect(source).toStrictEqual({a: {b: 1}}); + }); + + it("Copies functions and primitives by value/reference", () => { + const fn = (): number => 1; + const symbol = Symbol("s"); + const result = objectAssignDeep({}, {fn, symbol, big: 1n, nan: Number.NaN}); + + expect(result.fn).toBe(fn); + expect(result.symbol).toBe(symbol); + expect(result.big).toStrictEqual(1n); + expect(result.nan).toBeNaN(); + }); + + it("Reduces non-plain objects to their own enumerable properties", () => { + // documented (inherited) behaviour: only own enumerable properties survive, the prototype is lost + expect(objectAssignDeep({}, {date: new Date(0)})).toStrictEqual({date: {}}); + expect(objectAssignDeep({}, {regexp: /abc/g})).toStrictEqual({regexp: {}}); + expect(objectAssignDeep({}, {map: new Map([["k", 1]])})).toStrictEqual({map: {}}); + expect(objectAssignDeep({}, {set: new Set([1])})).toStrictEqual({set: {}}); + + class Device { + id = 1; + get computed(): number { + return 2; + } + } + + const result = objectAssignDeep({}, {device: new Device()}); + + expect(result.device).toStrictEqual({id: 1}); + expect(result.device).not.toBeInstanceOf(Device); + }); + + it("Merges deeply nested objects coming from multiple sources", () => { + const result = objectAssignDeep({}, {a: {b: {c: 1}}}, {a: {b: {d: 2}, e: 3}}); + + expect(result).toStrictEqual({a: {b: {c: 1, d: 2}, e: 3}}); + }); + + it("Never copies keys that could tamper with the prototype chain", () => { + const result = objectAssignDeep({}, parse('{"__proto__": {"polluted": "yes"}, "constructor": {"x": 1}, "prototype": {"y": 2}, "safe": 1}')); + + expect(result).toStrictEqual({safe: 1}); + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + expect(({} as {polluted?: string}).polluted).toBeUndefined(); + }); + + it("Never copies unsafe keys nested inside cloned objects", () => { + const result = objectAssignDeep({}, {nested: parse('{"__proto__": {"polluted": "yes"}, "constructor": 1, "prototype": 2, "safe": 1}')}); + + expect(result).toStrictEqual({nested: {safe: 1}}); + expect(Object.getPrototypeOf(result.nested)).toBe(Object.prototype); + }); + + it("Never copies unsafe keys when merging into an existing object", () => { + const result = objectAssignDeep({nested: {safe: 1}}, {nested: parse('{"__proto__": {"polluted": "yes"}, "other": 2}')}); + + expect(result).toStrictEqual({nested: {safe: 1, other: 2}}); + expect(Object.getPrototypeOf(result.nested)).toBe(Object.prototype); + }); + + it("Leaves every source untouched when given an empty target", () => { + const first = {a: {b: 1}}; + const second = {a: {c: 2}}; + const result = objectAssignDeep({}, first, second); + + expect(result).toStrictEqual({a: {b: 1, c: 2}}); + expect(result).not.toBe(first); + expect(result).not.toBe(second); + expect(result.a).not.toBe(first.a); + expect(first).toStrictEqual({a: {b: 1}}); + expect(second).toStrictEqual({a: {c: 2}}); + }); +}); diff --git a/test/settings.test.ts b/test/settings.test.ts index 051a441c0..e7df023f4 100644 --- a/test/settings.test.ts +++ b/test/settings.test.ts @@ -4,11 +4,15 @@ import "./mocks/data"; import fs from "node:fs"; import {dump, load} from "js-yaml"; -import objectAssignDeep from "object-assign-deep"; import mockedData from "../lib/util/data"; +import {objectAssignDeep} from "../lib/util/objectAssignDeep"; import * as settings from "../lib/util/settings"; +// mirrors the global `KeyValue`, which is not visible from the test project, previously implied by the untyped `object-assign-deep` +// biome-ignore lint/suspicious/noExplicitAny: freely mutated to build the expected settings +type ExpectedSettings = Record; + const configurationFile = mockedData.joinPath("configuration.yaml"); const devicesFile = mockedData.joinPath("devices.yaml"); const devicesFile2 = mockedData.joinPath("devices2.yaml"); @@ -86,8 +90,7 @@ describe("Settings", () => { it("Should return default settings", () => { write(configurationFile, {}); const s = settings.get(); - // @ts-expect-error workaround - const expected = objectAssignDeep.noMutate({}, settings.testing.defaults); + const expected: ExpectedSettings = objectAssignDeep({}, settings.testing.defaults); expected.devices = {}; expected.groups = {}; expect(s).toStrictEqual(expected); @@ -96,8 +99,7 @@ describe("Settings", () => { it("Should return settings", () => { write(configurationFile, {serial: {disable_led: true}}); const s = settings.get(); - // @ts-expect-error workaround - const expected = objectAssignDeep.noMutate({}, settings.testing.defaults); + const expected: ExpectedSettings = objectAssignDeep({}, settings.testing.defaults); expected.devices = {}; expected.groups = {}; expected.serial = {disable_led: true}; @@ -124,8 +126,7 @@ describe("Settings", () => { }, }; - // @ts-expect-error workaround - const expected = objectAssignDeep.noMutate({}, settings.testing.defaults); + const expected: ExpectedSettings = objectAssignDeep({}, settings.testing.defaults); expected.devices = { "0x00158d00018255df": { friendly_name: "0x00158d00018255df", @@ -178,8 +179,7 @@ describe("Settings", () => { write(configurationFile, {}); - // @ts-expect-error workaround - const expected = objectAssignDeep.noMutate({}, settings.testing.defaults); + const expected: ExpectedSettings = objectAssignDeep({}, settings.testing.defaults); expected.frontend.enabled = true; expected.frontend.port = 8099; expected.homeassistant.enabled = true; @@ -212,8 +212,7 @@ describe("Settings", () => { expect(settings.validate()).toStrictEqual([]); const s = settings.get(); - // @ts-expect-error workaround - const expected = objectAssignDeep.noMutate({groups: {}, devices: {}}, settings.testing.defaults); + const expected: ExpectedSettings = objectAssignDeep({}, {groups: {}, devices: {}}, settings.testing.defaults); expected.mqtt.password = "password-in-env-var"; expected.mqtt.server = "server"; expect(s).toStrictEqual(expected); diff --git a/test/settingsMigration.test.ts b/test/settingsMigration.test.ts index 3c9903333..3517f4b2b 100644 --- a/test/settingsMigration.test.ts +++ b/test/settingsMigration.test.ts @@ -3,8 +3,8 @@ import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} fr import * as data from "./mocks/data"; import {existsSync, readFileSync, rmSync, writeFileSync} from "node:fs"; -import objectAssignDeep from "object-assign-deep"; import mockedData from "../lib/util/data"; +import {objectAssignDeep} from "../lib/util/objectAssignDeep"; import * as settings from "../lib/util/settings"; import * as settingsMigration from "../lib/util/settingsMigration"; import path from "node:path"; @@ -278,8 +278,7 @@ describe("Settings Migration", () => { }); it("no change needed - only add version", () => { - // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); + const afterSettings = objectAssignDeep({}, settings.getPersistedSettings()); afterSettings.version = 2; settingsMigration.migrateIfNecessary(); @@ -290,10 +289,8 @@ describe("Settings Migration", () => { }); it("remove all", () => { - // @ts-expect-error workaround - const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); - // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); + const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings()); + const afterSettings = objectAssignDeep({}, settings.getPersistedSettings()); afterSettings.version = 2; settings.set(["homeassistant", "legacy_triggers"], true); @@ -320,8 +317,7 @@ describe("Settings Migration", () => { settings.set(["external_converters"], ["zyx.js"]); expect(settings.getPersistedSettings()).toStrictEqual( - // @ts-expect-error workaround - objectAssignDeep.noMutate(beforeSettings, { + objectAssignDeep({}, beforeSettings, { permit_join: true, homeassistant: { legacy_triggers: true, @@ -395,10 +391,8 @@ describe("Settings Migration", () => { }); it("remove partial", () => { - // @ts-expect-error workaround - const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); - // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); + const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings()); + const afterSettings = objectAssignDeep({}, settings.getPersistedSettings()); afterSettings.version = 2; settings.set(["advanced", "homeassistant_legacy_triggers"], true); @@ -417,8 +411,7 @@ describe("Settings Migration", () => { // console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2)); expect(settings.getPersistedSettings()).toStrictEqual( - // @ts-expect-error workaround - objectAssignDeep.noMutate(beforeSettings, { + objectAssignDeep({}, beforeSettings, { permit_join: true, advanced: { homeassistant_legacy_triggers: true, @@ -472,10 +465,8 @@ describe("Settings Migration", () => { }); it("changes log_level", () => { - // @ts-expect-error workaround - const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); - // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); + const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings()); + const afterSettings = objectAssignDeep({}, settings.getPersistedSettings()); afterSettings.version = 2; afterSettings.advanced = {log_level: "warning"}; @@ -484,8 +475,7 @@ describe("Settings Migration", () => { // console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2)); expect(settings.getPersistedSettings()).toStrictEqual( - // @ts-expect-error workaround - objectAssignDeep.noMutate(beforeSettings, { + objectAssignDeep({}, beforeSettings, { advanced: { log_level: "warn", }, @@ -505,10 +495,8 @@ describe("Settings Migration", () => { }); it("does not changes already migrated log_level", () => { - // @ts-expect-error workaround - const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); - // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); + const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings()); + const afterSettings = objectAssignDeep({}, settings.getPersistedSettings()); afterSettings.version = 2; afterSettings.advanced = {log_level: "warning"}; @@ -517,8 +505,7 @@ describe("Settings Migration", () => { // console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2)); expect(settings.getPersistedSettings()).toStrictEqual( - // @ts-expect-error workaround - objectAssignDeep.noMutate(beforeSettings, { + objectAssignDeep({}, beforeSettings, { advanced: { log_level: "warning", }, @@ -538,10 +525,8 @@ describe("Settings Migration", () => { }); it("does not changes other log_level", () => { - // @ts-expect-error workaround - const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); - // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); + const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings()); + const afterSettings = objectAssignDeep({}, settings.getPersistedSettings()); afterSettings.version = 2; afterSettings.advanced = {log_level: "info"}; @@ -550,8 +535,7 @@ describe("Settings Migration", () => { // console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2)); expect(settings.getPersistedSettings()).toStrictEqual( - // @ts-expect-error workaround - objectAssignDeep.noMutate(beforeSettings, { + objectAssignDeep({}, beforeSettings, { advanced: { log_level: "info", }, @@ -571,10 +555,8 @@ describe("Settings Migration", () => { }); it("transfer all", () => { - // @ts-expect-error workaround - const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); - // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); + const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings()); + const afterSettings = objectAssignDeep({}, settings.getPersistedSettings()); afterSettings.version = 2; afterSettings.advanced = { transmit_power: 12, @@ -604,8 +586,7 @@ describe("Settings Migration", () => { // console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2)); expect(settings.getPersistedSettings()).toStrictEqual( - // @ts-expect-error workaround - objectAssignDeep.noMutate(beforeSettings, { + objectAssignDeep({}, beforeSettings, { advanced: { homeassistant_discovery_topic: "ha_disc", homeassistant_status_topic: "ha_stat", @@ -649,10 +630,8 @@ describe("Settings Migration", () => { }); it("transfer partial", () => { - // @ts-expect-error workaround - const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); - // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); + const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings()); + const afterSettings = objectAssignDeep({}, settings.getPersistedSettings()); afterSettings.version = 2; afterSettings.advanced = {}; // caused by pushing to key and removing all afterSettings.serial.baudrate = 115200; @@ -673,8 +652,7 @@ describe("Settings Migration", () => { // console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2)); expect(settings.getPersistedSettings()).toStrictEqual( - // @ts-expect-error workaround - objectAssignDeep.noMutate(beforeSettings, { + objectAssignDeep({}, beforeSettings, { homeassistant: {discovery_topic: "ha_disc_newer"}, advanced: { homeassistant_discovery_topic: "ha_disc", @@ -718,10 +696,8 @@ describe("Settings Migration", () => { }); it("Update", () => { - // @ts-expect-error workaround - const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); - // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); + const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings()); + const afterSettings = objectAssignDeep({}, settings.getPersistedSettings()); afterSettings.version = 3; afterSettings.homeassistant = {enabled: false}; afterSettings.frontend = {enabled: true}; @@ -739,8 +715,7 @@ describe("Settings Migration", () => { settings.set(["experimental", "transmit_power"], 12); expect(settings.getPersistedSettings()).toStrictEqual( - // @ts-expect-error workaround - objectAssignDeep.noMutate(beforeSettings, { + objectAssignDeep({}, beforeSettings, { homeassistant: false, frontend: true, availability: {active: {timeout: 15}}, @@ -773,10 +748,8 @@ describe("Settings Migration", () => { }); it("Update", () => { - // @ts-expect-error workaround - const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); - // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); + const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings()); + const afterSettings = objectAssignDeep({}, settings.getPersistedSettings()); afterSettings.version = 3; afterSettings.homeassistant = {enabled: false}; afterSettings.frontend = {enabled: true}; @@ -787,8 +760,7 @@ describe("Settings Migration", () => { settings.set(["availability"], {active: {timeout: 15}}); expect(settings.getPersistedSettings()).toStrictEqual( - // @ts-expect-error workaround - objectAssignDeep.noMutate(beforeSettings, { + objectAssignDeep({}, beforeSettings, { homeassistant: false, frontend: true, availability: {active: {timeout: 15}}, @@ -809,18 +781,15 @@ describe("Settings Migration", () => { }); it("Update when not set, tests that frontend/availability is not added when not set", () => { - // @ts-expect-error workaround - const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); - // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); + const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings()); + const afterSettings = objectAssignDeep({}, settings.getPersistedSettings()); afterSettings.version = 3; afterSettings.homeassistant = {enabled: false}; settings.set(["homeassistant"], false); expect(settings.getPersistedSettings()).toStrictEqual( - // @ts-expect-error workaround - objectAssignDeep.noMutate(beforeSettings, { + objectAssignDeep({}, beforeSettings, { homeassistant: false, }), ); @@ -854,10 +823,8 @@ describe("Settings Migration", () => { }); it("Update", () => { - // @ts-expect-error workaround - const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); - // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); + const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings()); + const afterSettings = objectAssignDeep({}, settings.getPersistedSettings()); afterSettings.version = 4; afterSettings.devices = { "0x123127fffe8d96bc": { @@ -888,8 +855,7 @@ describe("Settings Migration", () => { }); expect(settings.getPersistedSettings()).toStrictEqual( - // @ts-expect-error workaround - objectAssignDeep.noMutate(beforeSettings, { + objectAssignDeep({}, beforeSettings, { devices: { "0x123127fffe8d96bc": { friendly_name: "0x847127fffe8d96bc", @@ -950,10 +916,8 @@ describe("Settings Migration", () => { }); it("Update", () => { - // @ts-expect-error workaround - const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); - // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); + const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings()); + const afterSettings = objectAssignDeep({}, settings.getPersistedSettings()); afterSettings.version = 5; expect(settings.getPersistedSettings()).toStrictEqual(beforeSettings); @@ -964,8 +928,7 @@ describe("Settings Migration", () => { const migratedSettings = settings.getPersistedSettings(); expect(migratedSettings).toStrictEqual(afterSettings); - // @ts-expect-error workaround - const migratedState = objectAssignDeep.noMutate({}, DEFAULT_STATE); + const migratedState = objectAssignDeep({}, DEFAULT_STATE); delete (migratedState["0x0017880104e45517"] as Record).update; delete (migratedState[1] as Record).update; @@ -976,10 +939,8 @@ describe("Settings Migration", () => { const consoleErrorSpy = vi.spyOn(console, "error"); writeFileSync(path.join(data.mockDir, "state.json"), "notjson", "utf8"); - // @ts-expect-error workaround - const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); - // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); + const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings()); + const afterSettings = objectAssignDeep({}, settings.getPersistedSettings()); afterSettings.version = 5; expect(settings.getPersistedSettings()).toStrictEqual(beforeSettings);