feat: Live loading of external JS (converters/extensions) (#24764)

* feat: Live loading of external JS (converters/extensions)

* Fix imports

* Improve error message on MQTT save

* Handle non-existing base path

* Throw on bad converter

* Add tests

* Fix use of ext conv in network map tests.

* More coverage.

* Dont mock zhc for basics, tests actual live loading

* Update

* feat: Live loading of external JS (converters/extensions)

* Fix imports

* Improve error message on MQTT save

* Handle non-existing base path

* Throw on bad converter

* Add tests

* Fix use of ext conv in network map tests.

* More coverage.

* Dont mock zhc for basics, tests actual live loading

* Update

* Fix rebase

* Fix

* Bump zhc

* pretty

* fix typing

* Cleanup `external_converters` setting remnants.

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
This commit is contained in:
Nerivec
2024-12-01 20:11:34 +01:00
committed by Koen Kanters
co-authored by Koen Kanters
parent cc61f67215
commit 9907005256
22 changed files with 757 additions and 484 deletions
+4 -7
View File
@@ -15,7 +15,7 @@ import ExtensionBind from './extension/bind';
import ExtensionBridge from './extension/bridge';
import ExtensionConfigure from './extension/configure';
import ExtensionExternalConverters from './extension/externalConverters';
import ExtensionExternalExtension from './extension/externalExtension';
import ExtensionExternalExtensions from './extension/externalExtensions';
// Extensions
import ExtensionFrontend from './extension/frontend';
import ExtensionGroups from './extension/groups';
@@ -47,7 +47,7 @@ const AllExtensions = [
ExtensionOTAUpdate,
ExtensionExternalConverters,
ExtensionFrontend,
ExtensionExternalExtension,
ExtensionExternalExtensions,
ExtensionAvailability,
];
@@ -106,7 +106,8 @@ export class Controller {
new ExtensionGroups(...this.extensionArgs),
new ExtensionBind(...this.extensionArgs),
new ExtensionOTAUpdate(...this.extensionArgs),
new ExtensionExternalExtension(...this.extensionArgs),
new ExtensionExternalExtensions(...this.extensionArgs),
new ExtensionExternalConverters(...this.extensionArgs),
new ExtensionAvailability(...this.extensionArgs),
];
@@ -114,10 +115,6 @@ export class Controller {
this.extensions.push(new ExtensionFrontend(...this.extensionArgs));
}
if (settings.get().external_converters.length) {
this.extensions.push(new ExtensionExternalConverters(...this.extensionArgs));
}
if (settings.get().homeassistant) {
this.extensions.push(new ExtensionHomeAssistant(...this.extensionArgs));
}
+50 -22
View File
@@ -1,11 +1,13 @@
import * as zhc from 'zigbee-herdsman-converters';
import type * as zhc from 'zigbee-herdsman-converters';
import {addDefinition, removeExternalDefinitions} from 'zigbee-herdsman-converters';
import logger from '../util/logger';
import * as settings from '../util/settings';
import {loadExternalConverter} from '../util/utils';
import Extension from './extension';
import ExternalJSExtension from './externalJS';
export default class ExternalConverters extends Extension {
type ModuleExports = zhc.Definition | zhc.Definition[];
export default class ExternalConverters extends ExternalJSExtension<ModuleExports> {
constructor(
zigbee: Zigbee,
mqtt: MQTT,
@@ -16,25 +18,51 @@ export default class ExternalConverters extends Extension {
restartCallback: () => Promise<void>,
addExtension: (extension: Extension) => Promise<void>,
) {
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
super(
zigbee,
mqtt,
state,
publishEntityState,
eventBus,
enableDisableExtension,
restartCallback,
addExtension,
'converter',
'external_converters',
);
}
for (const file of settings.get().external_converters) {
try {
for (const definition of loadExternalConverter(file)) {
zhc.addDefinition(definition);
}
logger.info(`Loaded external converter '${file}'`);
} catch (error) {
logger.error(`Failed to load external converter file '${file}' (${(error as Error).message})`);
logger.error(
`Probably there is a syntax error in the file or the external converter is not ` +
`compatible with the current Zigbee2MQTT version`,
);
logger.error(
`Note that external converters are not meant for long term usage, it's meant for local ` +
`testing after which a pull request should be created to add out-of-the-box support for the device`,
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected async removeJS(name: string, module: ModuleExports): Promise<void> {
removeExternalDefinitions(name);
await this.zigbee.resolveDevicesDefinitions(true);
}
protected async loadJS(name: string, module: ModuleExports): Promise<void> {
try {
removeExternalDefinitions(name);
for (const definition of this.getDefinitions(module)) {
definition.externalConverterName = name;
addDefinition(definition);
logger.info(`Loaded external converter '${name}'.`);
}
await this.zigbee.resolveDevicesDefinitions(true);
} catch (error) {
logger.error(`Failed to load external converter '${name}'`);
logger.error(`Check the code for syntax error and make sure it is up to date with the current Zigbee2MQTT version.`);
logger.error(
`External converters are not meant for long term usage, but for local testing after which a pull request should be created to add out-of-the-box support for the device`,
);
throw error;
}
}
private getDefinitions(module: ModuleExports): zhc.Definition[] {
return Array.isArray(module) ? module : [module];
}
}
-120
View File
@@ -1,120 +0,0 @@
import fs from 'fs';
import path from 'path';
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import * as settings from '../util/settings';
import utils from '../util/utils';
import data from './../util/data';
import logger from './../util/logger';
import Extension from './extension';
const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/extension/(save|remove)`);
export default class ExternalExtension extends Extension {
private requestLookup: {[s: string]: (message: KeyValue) => Promise<MQTTResponse>} = {
save: this.saveExtension,
remove: this.removeExtension,
};
override async start(): Promise<void> {
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
await this.loadUserDefinedExtensions();
await this.publishExtensions();
}
private getExtensionsBasePath(): string {
return data.joinPath('extension');
}
private getListOfUserDefinedExtensions(): {name: string; code: string}[] {
const basePath = this.getExtensionsBasePath();
if (fs.existsSync(basePath)) {
return fs
.readdirSync(basePath)
.filter((f) => f.endsWith('.js'))
.map((fileName) => {
const extensionFilePath = path.join(basePath, fileName);
return {name: fileName, code: fs.readFileSync(extensionFilePath, 'utf-8')};
});
} else {
return [];
}
}
@bind private async removeExtension(message: KeyValue): Promise<MQTTResponse> {
const {name} = message;
const extensions = this.getListOfUserDefinedExtensions();
const extensionToBeRemoved = extensions.find((e) => e.name === name);
if (extensionToBeRemoved) {
await this.enableDisableExtension(false, extensionToBeRemoved.name);
const basePath = this.getExtensionsBasePath();
const extensionFilePath = path.join(basePath, path.basename(name));
fs.unlinkSync(extensionFilePath);
await this.publishExtensions();
logger.info(`Extension ${name} removed`);
return utils.getResponse(message, {});
} else {
return utils.getResponse(message, {}, `Extension ${name} doesn't exists`);
}
}
@bind private async saveExtension(message: KeyValue): Promise<MQTTResponse> {
const {name, code} = message;
const ModuleConstructor = utils.loadModuleFromText(code, name) as typeof Extension;
await this.loadExtension(ModuleConstructor);
const basePath = this.getExtensionsBasePath();
/* istanbul ignore else */
if (!fs.existsSync(basePath)) {
fs.mkdirSync(basePath);
}
const extensionFilePath = path.join(basePath, path.basename(name));
fs.writeFileSync(extensionFilePath, code);
await this.publishExtensions();
logger.info(`Extension ${name} loaded`);
return utils.getResponse(message, {});
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const match = data.topic.match(requestRegex);
if (match && this.requestLookup[match[1].toLowerCase()]) {
const message = utils.parseJSON(data.message, data.message) as KeyValue;
try {
const response = await this.requestLookup[match[1].toLowerCase()](message);
await this.mqtt.publish(`bridge/response/extension/${match[1]}`, stringify(response));
} catch (error) {
logger.error(`Request '${data.topic}' failed with error: '${(error as Error).message}'`);
const response = utils.getResponse(message, {}, `${(error as Error).message}`);
await this.mqtt.publish(`bridge/response/extension/${match[1]}`, stringify(response));
}
}
}
@bind private async loadExtension(ConstructorClass: typeof Extension): Promise<void> {
await this.enableDisableExtension(false, ConstructorClass.name);
// @ts-expect-error `ConstructorClass` is the interface, not the actual passed class
await this.addExtension(new ConstructorClass(this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus, settings, logger));
}
private async loadUserDefinedExtensions(): Promise<void> {
for (const extension of this.getListOfUserDefinedExtensions()) {
await this.loadExtension(utils.loadModuleFromText(extension.code, extension.name) as typeof Extension);
}
}
private async publishExtensions(): Promise<void> {
const extensions = this.getListOfUserDefinedExtensions();
await this.mqtt.publish(
'bridge/extensions',
stringify(extensions),
{
retain: true,
qos: 0,
},
settings.get().mqtt.base_topic,
true,
);
}
}
+59
View File
@@ -0,0 +1,59 @@
import type Extension from './extension';
import logger from '../util/logger';
import * as settings from '../util/settings';
import ExternalJSExtension from './externalJS';
type ModuleExports = typeof Extension;
export default class ExternalExtensions extends ExternalJSExtension<ModuleExports> {
constructor(
zigbee: Zigbee,
mqtt: MQTT,
state: State,
publishEntityState: PublishEntityState,
eventBus: EventBus,
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
restartCallback: () => Promise<void>,
addExtension: (extension: Extension) => Promise<void>,
) {
super(
zigbee,
mqtt,
state,
publishEntityState,
eventBus,
enableDisableExtension,
restartCallback,
addExtension,
'extension',
'external_extensions',
);
}
protected async removeJS(name: string, module: ModuleExports): Promise<void> {
await this.enableDisableExtension(false, module.name);
}
protected async loadJS(name: string, module: ModuleExports): Promise<void> {
// stop if already started
await this.enableDisableExtension(false, module.name);
await this.addExtension(
// @ts-expect-error `module` is the interface, not the actual passed class
new module(
this.zigbee,
this.mqtt,
this.state,
this.publishEntityState,
this.eventBus,
this.enableDisableExtension,
this.restartCallback,
this.addExtension,
settings,
logger,
),
);
logger.info(`Loaded external extension '${name}'.`);
}
}
+171
View File
@@ -0,0 +1,171 @@
import fs from 'fs';
import path from 'path';
import {Context, runInNewContext} from 'vm';
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import data from '../util/data';
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
export default abstract class ExternalJSExtension<M> extends Extension {
private requestLookup: {[s: string]: (message: KeyValue) => Promise<MQTTResponse>} = {
save: this.save,
remove: this.remove,
};
protected mqttTopic: string;
protected requestRegex: RegExp;
protected basePath: string;
constructor(
zigbee: Zigbee,
mqtt: MQTT,
state: State,
publishEntityState: PublishEntityState,
eventBus: EventBus,
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
restartCallback: () => Promise<void>,
addExtension: (extension: Extension) => Promise<void>,
mqttTopic: string,
folderName: string,
) {
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
this.mqttTopic = mqttTopic;
this.requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/${mqttTopic}/(save|remove)`);
this.basePath = data.joinPath(folderName);
}
override async start(): Promise<void> {
await super.start();
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
await this.loadFiles();
await this.publishExternalJS();
}
private getFilePath(name: string, mkBasePath: boolean = false): string {
if (mkBasePath && !fs.existsSync(this.basePath)) {
fs.mkdirSync(this.basePath, {recursive: true});
}
return path.join(this.basePath, name);
}
protected getFileCode(name: string): string {
return fs.readFileSync(path.join(this.basePath, name), 'utf8');
}
protected *getFiles(): Generator<{name: string; code: string}> {
if (!fs.existsSync(this.basePath)) {
return;
}
for (const fileName of fs.readdirSync(this.basePath)) {
/* istanbul ignore else */
if (fileName.endsWith('.js')) {
yield {name: fileName, code: this.getFileCode(fileName)};
}
}
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const match = data.topic.match(this.requestRegex);
if (match && this.requestLookup[match[1].toLowerCase()]) {
const message = utils.parseJSON(data.message, data.message) as KeyValue;
try {
const response = await this.requestLookup[match[1].toLowerCase()](message);
await this.mqtt.publish(`bridge/response/${this.mqttTopic}/${match[1]}`, stringify(response));
} catch (error) {
logger.error(`Request '${data.topic}' failed with error: '${(error as Error).message}'`);
const response = utils.getResponse(message, {}, `${(error as Error).message}`);
await this.mqtt.publish(`bridge/response/${this.mqttTopic}/${match[1]}`, stringify(response));
}
}
}
protected abstract removeJS(name: string, module: M): Promise<void>;
protected abstract loadJS(name: string, module: M): Promise<void>;
@bind private async remove(message: KeyValue): Promise<MQTTResponse> {
const {name} = message;
const toBeRemoved = this.getFilePath(name);
if (fs.existsSync(toBeRemoved)) {
await this.removeJS(name, this.loadModuleFromText(this.getFileCode(name), name));
fs.rmSync(toBeRemoved, {force: true});
logger.info(`${name} (${toBeRemoved}) removed.`);
await this.publishExternalJS();
return utils.getResponse(message, {});
} else {
return utils.getResponse(message, {}, `${name} (${toBeRemoved}) doesn't exists`);
}
}
@bind private async save(message: KeyValue): Promise<MQTTResponse> {
const {name, code} = message;
try {
await this.loadJS(name, this.loadModuleFromText(code, name));
const filePath = this.getFilePath(name, true);
fs.writeFileSync(filePath, code, 'utf8');
logger.info(`${name} loaded. Contents written to '${filePath}'.`);
await this.publishExternalJS();
return utils.getResponse(message, {});
} catch (error) {
return utils.getResponse(message, {}, `${name} contains invalid code: ${(error as Error).message}`);
}
}
private async loadFiles(): Promise<void> {
for (const extension of this.getFiles()) {
await this.loadJS(extension.name, this.loadModuleFromText(extension.code, extension.name));
}
}
private async publishExternalJS(): Promise<void> {
await this.mqtt.publish(
`bridge/${this.mqttTopic}s`,
stringify(Array.from(this.getFiles())),
{
retain: true,
qos: 0,
},
settings.get().mqtt.base_topic,
true,
);
}
private loadModuleFromText(moduleCode: string, name: string): M {
const moduleFakePath = path.join(__dirname, '..', '..', 'data', 'extension', name);
const sandbox: Context = {
require: require,
module: {},
console,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
setImmediate,
clearImmediate,
};
runInNewContext(moduleCode, sandbox, moduleFakePath);
return sandbox.module.exports;
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ export default class Device {
}
}
async resolveDefinition(ignoreCache = false): Promise<void> {
async resolveDefinition(ignoreCache: boolean = false): Promise<void> {
if (!this.zh.interviewing && (!this.definition || this._definitionModelID !== this.zh.modelID || ignoreCache)) {
this.definition = await zhc.findByDevice(this.zh, true);
this._definitionModelID = this.zh.modelID;
-1
View File
@@ -116,7 +116,6 @@ declare global {
active: {timeout: number};
passive: {timeout: number};
};
external_converters: string[];
mqtt: {
base_topic: string;
include_device_information: boolean;
-10
View File
@@ -86,16 +86,6 @@
"requiresRestart": true,
"description": "Checks whether devices are online/offline"
},
"external_converters": {
"type": "array",
"title": "External converters",
"description": "You can define external converters to e.g. add support for a DiY device",
"requiresRestart": true,
"items": {
"type": "string"
},
"examples": ["DIYRuZ_FreePad.js"]
},
"mqtt": {
"type": "object",
"title": "MQTT",
-1
View File
@@ -25,7 +25,6 @@ const ajvRestartRequiredGroupOptions = new Ajv({allErrors: true})
.addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s})
.compile(schemaJson.definitions.group);
const defaults: RecursivePartial<Settings> = {
external_converters: [],
mqtt: {
base_topic: 'zigbee2mqtt',
include_device_information: false,
-47
View File
@@ -3,13 +3,10 @@ import type * as zhc from 'zigbee-herdsman-converters';
import assert from 'assert';
import fs from 'fs';
import path from 'path';
import vm from 'vm';
import equals from 'fast-deep-equal/es6';
import humanizeDuration from 'humanize-duration';
import data from './data';
function pad(num: number): string {
const norm = Math.floor(Math.abs(num));
return (norm < 10 ? '0' : '') + norm;
@@ -145,48 +142,6 @@ function parseJSON(value: string, fallback: string): KeyValue | string {
}
}
function loadModuleFromText(moduleCode: string, name?: string): unknown {
const moduleFakePath = path.join(__dirname, '..', '..', 'data', 'extension', name || 'externally-loaded.js');
const sandbox = {
require: require,
module: {},
console,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
setImmediate,
clearImmediate,
};
vm.runInNewContext(moduleCode, sandbox, moduleFakePath);
/* eslint-disable-line */ // @ts-ignore
return sandbox.module.exports;
}
function loadModuleFromFile(modulePath: string): unknown {
const moduleCode = fs.readFileSync(modulePath, {encoding: 'utf8'});
return loadModuleFromText(moduleCode);
}
export function* loadExternalConverter(moduleName: string): Generator<zhc.Definition> {
let converter;
if (moduleName.endsWith('.js')) {
converter = loadModuleFromFile(data.joinPath(moduleName));
} else {
// eslint-disable-next-line @typescript-eslint/no-require-imports
converter = require(moduleName);
}
if (Array.isArray(converter)) {
for (const item of converter) {
yield item;
}
} else {
yield converter;
}
}
/**
* Delete all keys from passed object that have null/undefined values.
*
@@ -426,8 +381,6 @@ export default {
getObjectProperty,
getResponse,
parseJSON,
loadModuleFromText,
loadModuleFromFile,
removeNullPropertiesFromObject,
toNetworkAddressHex,
toSnakeCaseString,
+7 -3
View File
@@ -72,9 +72,7 @@ export default class Zigbee {
throw error;
}
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
await device.resolveDefinition();
}
await this.resolveDevicesDefinitions();
this.herdsman.on('adapterDisconnected', () => this.eventBus.emitAdapterDisconnected());
this.herdsman.on('lastSeenChanged', (data: ZHEvents.LastSeenChangedPayload) => {
@@ -239,6 +237,12 @@ export default class Zigbee {
await this.herdsman.permitJoin(time, device?.zh);
}
async resolveDevicesDefinitions(ignoreCache: boolean = false): Promise<void> {
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
await device.resolveDefinition(ignoreCache);
}
}
@bind private resolveDevice(ieeeAddr: string): Device | undefined {
if (!this.deviceLookup[ieeeAddr]) {
const device = this.herdsman.getDeviceByIeeeAddr(ieeeAddr);
@@ -0,0 +1,16 @@
class Example2 {
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
this.mqtt = mqtt;
this.mqtt.publish('example2/extension', 'call2 from constructor');
}
start() {
this.mqtt.publish('example2/extension', 'call2 from start');
}
stop() {
this.mqtt.publish('example/extension', 'call2 from stop');
}
}
module.exports = Example2;
@@ -5,7 +5,11 @@ class Example {
}
start() {
this.mqtt.publish('example/extension', 'test');
this.mqtt.publish('example/extension', 'call from start');
}
stop() {
this.mqtt.publish('example/extension', 'call from stop');
}
}
+2 -3
View File
@@ -181,7 +181,6 @@ describe('Extension: Bridge', () => {
retain: false,
},
},
external_converters: [],
groups: {
1: {friendly_name: 'group_1', retain: false},
11: {friendly_name: 'group_with_tradfri', retain: false},
@@ -3781,11 +3780,11 @@ describe('Extension: Bridge', () => {
it('Change options not valid against schema', async () => {
mockMQTT.publishAsync.mockClear();
mockMQTTEvents.message('zigbee2mqtt/bridge/request/options', stringify({options: {external_converters: 'true'}}));
mockMQTTEvents.message('zigbee2mqtt/bridge/request/options', stringify({options: {advanced: {log_level: 123}}}));
await flushPromises();
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/options',
stringify({data: {}, error: 'external_converters must be array', status: 'error'}),
stringify({data: {}, error: 'advanced/log_level must be string', status: 'error'}),
{retain: false, qos: 0},
);
});
+265 -128
View File
@@ -1,169 +1,306 @@
import * as data from '../mocks/data';
import {mockLogger} from '../mocks/logger';
import {mockMQTT} from '../mocks/mqtt';
import {mockMQTT, events as mockMQTTEvents} from '../mocks/mqtt';
import {flushPromises} from '../mocks/utils';
import {devices, mockController as mockZHController} from '../mocks/zigbeeHerdsman';
import {devices, mockController as mockZHController, returnDevices} from '../mocks/zigbeeHerdsman';
import type Device from '../../lib/model/device';
import fs from 'fs';
import path from 'path';
import stringify from 'json-stable-stringify-without-jsonify';
import * as zhc from 'zigbee-herdsman-converters';
import {Controller} from '../../lib/controller';
import * as settings from '../../lib/util/settings';
jest.mock(
'mock-external-converter-module',
() => {
return {
mock: true,
};
},
{
virtual: true,
},
);
jest.mock(
'mock-multiple-external-converter-module',
() => {
return [
{
mock: 1,
},
{
mock: 2,
},
];
},
{
virtual: true,
},
);
const mockZHCAddDefinition = jest.fn();
// @ts-expect-error mock
zhc.addDefinition = mockZHCAddDefinition;
const mocksClear = [
mockZHCAddDefinition,
devices.bulb_color.removeFromNetwork,
devices.bulb.removeFromNetwork,
mockZHController.permitJoin,
mockZHController.stop,
mockMQTT.endAsync,
mockMQTT.publishAsync,
mockLogger.debug,
mockLogger.error,
];
const BASE_DIR = 'external_converters';
describe('Extension: ExternalConverters', () => {
const mockBasePath = path.join(data.mockDir, BASE_DIR);
let controller: Controller;
const resetExtension = async (): Promise<void> => {
await controller.enableDisableExtension(false, 'ExternalConverters');
await controller.enableDisableExtension(true, 'ExternalConverters');
const existsSyncSpy = jest.spyOn(fs, 'existsSync');
const readdirSyncSpy = jest.spyOn(fs, 'readdirSync');
const mkdirSyncSpy = jest.spyOn(fs, 'mkdirSync');
const rmSyncSpy = jest.spyOn(fs, 'rmSync');
const writeFileSyncSpy = jest.spyOn(fs, 'writeFileSync');
const zhcAddDefinitionSpy = jest.spyOn(zhc, 'addDefinition');
const zhcRemoveExternalDefinitionsSpy = jest.spyOn(zhc, 'removeExternalDefinitions');
const mocksClear = [
mockMQTT.endAsync,
mockMQTT.publishAsync,
mockLogger.debug,
mockLogger.error,
mockZHController.stop,
devices.bulb.save,
existsSyncSpy,
readdirSyncSpy,
mkdirSyncSpy,
rmSyncSpy,
writeFileSyncSpy,
zhcAddDefinitionSpy,
zhcRemoveExternalDefinitionsSpy,
];
const useAssets = (): void => {
fs.cpSync(path.join(__dirname, '..', 'assets', BASE_DIR), mockBasePath, {recursive: true});
};
const getFileCode = (fileName: string): string => {
return fs.readFileSync(path.join(__dirname, '..', 'assets', BASE_DIR, fileName), 'utf8');
};
const getZ2MDevice = (zhDevice: unknown): Device => {
// @ts-expect-error private
return controller.zigbee.resolveEntity(zhDevice)! as Device;
};
beforeAll(async () => {
jest.useFakeTimers();
controller = new Controller(jest.fn(), jest.fn());
await controller.start();
await flushPromises();
});
beforeEach(async () => {
data.writeDefaultConfiguration();
settings.reRead();
mocksClear.forEach((m) => m.mockClear());
await resetExtension();
});
afterAll(async () => {
jest.useRealTimers();
});
it('Does not load external converters', async () => {
settings.set(['external_converters'], []);
await resetExtension();
expect(mockZHCAddDefinition).toHaveBeenCalledTimes(0);
beforeEach(async () => {
zhc.removeExternalDefinitions(); // remove all external converters
mocksClear.forEach((m) => m.mockClear());
data.writeDefaultConfiguration();
data.writeDefaultState();
settings.reRead();
returnDevices.push(devices.external_converter_device.ieeeAddr);
controller = new Controller(jest.fn(), jest.fn());
});
it('Loads external converters', async () => {
fs.copyFileSync(path.join(__dirname, '..', 'assets', 'mock-external-converter.js'), path.join(data.mockDir, 'mock-external-converter.js'));
settings.set(['external_converters'], ['mock-external-converter.js']);
await resetExtension();
expect(mockZHCAddDefinition).toHaveBeenCalledTimes(1);
expect(mockZHCAddDefinition).toHaveBeenCalledWith({
mock: true,
zigbeeModel: ['external_converter_device'],
vendor: 'external',
model: 'external_converter_device',
afterEach(async () => {
fs.rmSync(mockBasePath, {recursive: true, force: true});
await controller?.stop();
});
it('loads nothing from folder', async () => {
await controller.start();
await flushPromises();
expect(existsSyncSpy).toHaveBeenCalledWith(mockBasePath);
expect(readdirSyncSpy).not.toHaveBeenCalledWith(mockBasePath);
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/converters', stringify([]), {retain: true, qos: 0});
});
it('loads from folder', async () => {
useAssets();
await controller.start();
await flushPromises();
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
description: 'external',
fromZigbee: [],
toZigbee: [],
exposes: [],
model: 'external_converter_device',
vendor: 'external',
zigbeeModel: ['external_converter_device'],
});
});
it('Loads multiple external converters', async () => {
fs.copyFileSync(
path.join(__dirname, '..', 'assets', 'mock-external-converter-multiple.js'),
path.join(data.mockDir, 'mock-external-converter-multiple.js'),
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/converters',
stringify([
{name: 'mock-external-converter-multiple.js', code: getFileCode('mock-external-converter-multiple.js')},
{name: 'mock-external-converter.js', code: getFileCode('mock-external-converter.js')},
]),
{retain: true, qos: 0},
);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(1, 'mock-external-converter-multiple.js');
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, 'mock-external-converter.js');
expect(zhcAddDefinitionSpy).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
mock: 1,
model: 'external_converters_device_1',
zigbeeModel: ['external_converter_device_1'],
vendor: 'external_1',
description: 'external_1',
}),
);
expect(zhcAddDefinitionSpy).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
mock: 2,
model: 'external_converters_device_2',
zigbeeModel: ['external_converter_device_2'],
vendor: 'external_2',
description: 'external_2',
}),
);
expect(zhcAddDefinitionSpy).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
mock: true,
zigbeeModel: ['external_converter_device'],
vendor: 'external',
model: 'external_converter_device',
description: 'external',
}),
);
settings.set(['external_converters'], ['mock-external-converter-multiple.js']);
await resetExtension();
expect(mockZHCAddDefinition).toHaveBeenCalledTimes(2);
expect(mockZHCAddDefinition).toHaveBeenNthCalledWith(1, {
mock: 1,
model: 'external_converters_device_1',
zigbeeModel: ['external_converter_device_1'],
vendor: 'external_1',
description: 'external_1',
fromZigbee: [],
toZigbee: [],
exposes: [],
});
expect(mockZHCAddDefinition).toHaveBeenNthCalledWith(2, {
mock: 2,
model: 'external_converters_device_2',
zigbeeModel: ['external_converter_device_2'],
vendor: 'external_2',
description: 'external_2',
fromZigbee: [],
toZigbee: [],
exposes: [],
});
});
it('Loads external converters from package', async () => {
settings.set(['external_converters'], ['mock-external-converter-module']);
await resetExtension();
expect(mockZHCAddDefinition).toHaveBeenCalledTimes(1);
expect(mockZHCAddDefinition).toHaveBeenCalledWith({
mock: true,
it('saves and removes from MQTT', async () => {
const converterName = 'foo.js';
const converterCode = getFileCode('mock-external-converter.js');
const converterFilePath = path.join(mockBasePath, converterName);
await controller.start();
await flushPromises();
mocksClear.forEach((m) => m.mockClear());
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
description: 'Automatically generated definition',
model: 'external_converter_device',
vendor: '',
zigbeeModel: ['external_converter_device'],
});
//-- SAVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
await flushPromises();
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
description: 'external',
model: 'external_converter_device',
vendor: 'external',
zigbeeModel: ['external_converter_device'],
});
expect(mkdirSyncSpy).toHaveBeenCalledWith(mockBasePath, {recursive: true});
expect(writeFileSyncSpy).toHaveBeenCalledWith(converterFilePath, converterCode, 'utf8');
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(1);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(1, converterName);
expect(zhcAddDefinitionSpy).toHaveBeenCalledWith(
expect.objectContaining({
mock: true,
zigbeeModel: ['external_converter_device'],
vendor: 'external',
model: 'external_converter_device',
description: 'external',
}),
);
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/converters', stringify([{name: converterName, code: converterCode}]), {
retain: true,
qos: 0,
});
//-- REMOVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName}));
await flushPromises();
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
description: 'Automatically generated definition',
model: 'external_converter_device',
vendor: '',
zigbeeModel: ['external_converter_device'],
});
expect(rmSyncSpy).toHaveBeenCalledWith(converterFilePath, {force: true});
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2);
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, converterName);
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/converters', stringify([]), {retain: true, qos: 0});
});
it('Loads multiple external converters from package', async () => {
settings.set(['external_converters'], ['mock-multiple-external-converter-module']);
await resetExtension();
expect(mockZHCAddDefinition).toHaveBeenCalledTimes(2);
expect(mockZHCAddDefinition).toHaveBeenNthCalledWith(1, {
mock: 1,
});
expect(mockZHCAddDefinition).toHaveBeenNthCalledWith(2, {
mock: 2,
});
it('returns error on invalid code', async () => {
const converterName = 'foo.js';
const converterCode = 'definetly not a correct javascript code';
const converterFilePath = path.join(mockBasePath, converterName);
await controller.start();
await flushPromises();
mocksClear.forEach((m) => m.mockClear());
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
await flushPromises();
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/converter/save',
expect.stringContaining(`"error":"foo.js contains invalid code`),
{retain: false, qos: 0},
);
expect(writeFileSyncSpy).not.toHaveBeenCalledWith(converterFilePath, converterCode, 'utf8');
});
it('Loads external converters with error', async () => {
fs.copyFileSync(path.join(__dirname, '..', 'assets', 'mock-external-converter.js'), path.join(data.mockDir, 'mock-external-converter.js'));
settings.set(['external_converters'], ['mock-external-converter.js']);
mockZHCAddDefinition.mockImplementationOnce(() => {
throw new Error('Invalid definition!');
it('returns error on invalid removal', async () => {
const converterName = 'invalid.js';
const converterFilePath = path.join(mockBasePath, converterName);
await controller.start();
await flushPromises();
mocksClear.forEach((m) => m.mockClear());
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName}));
await flushPromises();
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/converter/remove',
stringify({data: {}, status: 'error', error: `${converterName} (${converterFilePath}) doesn't exists`}),
{retain: false, qos: 0},
);
expect(rmSyncSpy).not.toHaveBeenCalledWith(converterFilePath, {force: true});
});
it('returns error on invalid definition', async () => {
const converterName = 'foo.js';
const converterCode = getFileCode('mock-external-converter.js');
const converterFilePath = path.join(mockBasePath, converterName);
await controller.start();
await flushPromises();
mocksClear.forEach((m) => m.mockClear());
const errorMsg = `Invalid definition`;
zhcAddDefinitionSpy.mockImplementationOnce(() => {
throw new Error(errorMsg);
});
await resetExtension();
expect(mockLogger.error).toHaveBeenCalledWith(`Failed to load external converter file 'mock-external-converter.js' (Invalid definition!)`);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
await flushPromises();
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/response/converter/save', expect.stringContaining(errorMsg), {
retain: false,
qos: 0,
});
expect(writeFileSyncSpy).not.toHaveBeenCalledWith(converterFilePath, converterCode, 'utf8');
});
it('returns error on failed removal', async () => {
const converterName = 'foo.js';
const converterCode = getFileCode('mock-external-converter.js');
const converterFilePath = path.join(mockBasePath, converterName);
await controller.start();
await flushPromises();
mocksClear.forEach((m) => m.mockClear());
//-- SAVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
await flushPromises();
const errorMsg = `Failed to remove definition`;
zhcRemoveExternalDefinitionsSpy.mockImplementationOnce(() => {
throw new Error(errorMsg);
});
//-- REMOVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName}));
await flushPromises();
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/converter/remove',
stringify({data: {}, status: 'error', error: errorMsg}),
{retain: false, qos: 0},
);
expect(rmSyncSpy).not.toHaveBeenCalledWith(converterFilePath, {force: true});
});
});
-134
View File
@@ -1,134 +0,0 @@
import * as data from '../mocks/data';
import {mockLogger} from '../mocks/logger';
import {mockMQTT, events as mockMQTTEvents} from '../mocks/mqtt';
import {flushPromises} from '../mocks/utils';
import {devices, mockController as mockZHController, returnDevices} from '../mocks/zigbeeHerdsman';
import fs from 'fs';
import path from 'path';
import stringify from 'json-stable-stringify-without-jsonify';
import {rimrafSync} from 'rimraf';
import {Controller} from '../../lib/controller';
import * as settings from '../../lib/util/settings';
const mocksClear = [
mockZHController.permitJoin,
mockZHController.stop,
devices.bulb_color.removeFromNetwork,
devices.bulb.removeFromNetwork,
mockMQTT.endAsync,
mockMQTT.publishAsync,
mockLogger.debug,
mockLogger.error,
];
describe('Extension: ExternalExtension', () => {
let controller: Controller;
let mkdirSyncSpy: jest.SpyInstance;
let unlinkSyncSpy: jest.SpyInstance;
beforeAll(async () => {
jest.useFakeTimers();
mkdirSyncSpy = jest.spyOn(fs, 'mkdirSync');
unlinkSyncSpy = jest.spyOn(fs, 'unlinkSync');
});
afterAll(async () => {
jest.useRealTimers();
});
beforeEach(async () => {
data.writeDefaultConfiguration();
settings.reRead();
mocksClear.forEach((m) => m.mockClear());
returnDevices.splice(0);
mocksClear.forEach((m) => m.mockClear());
data.writeDefaultConfiguration();
settings.reRead();
data.writeDefaultState();
});
afterEach(() => {
const extensionPath = path.join(data.mockDir, 'extension');
rimrafSync(extensionPath);
});
it('Load user extension', async () => {
const extensionPath = path.join(data.mockDir, 'extension');
const extensionCode = fs.readFileSync(path.join(__dirname, '..', 'assets', 'exampleExtension.js'), 'utf-8');
fs.mkdirSync(extensionPath);
fs.copyFileSync(path.join(__dirname, '..', 'assets', 'exampleExtension.js'), path.join(extensionPath, 'exampleExtension.js'));
controller = new Controller(jest.fn(), jest.fn());
await controller.start();
await flushPromises();
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'test', {retain: false, qos: 0});
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/extensions',
stringify([{name: 'exampleExtension.js', code: extensionCode}]),
{retain: true, qos: 0},
);
});
it('Load user extension from api call', async () => {
const extensionPath = path.join(data.mockDir, 'extension');
const extensionCode = fs.readFileSync(path.join(__dirname, '..', 'assets', 'exampleExtension.js'), 'utf-8');
controller = new Controller(jest.fn(), jest.fn());
await controller.start();
await flushPromises();
mockMQTT.publishAsync.mockClear();
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: 'foo.js', code: extensionCode}));
await flushPromises();
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/extensions', stringify([{name: 'foo.js', code: extensionCode}]), {
retain: true,
qos: 0,
});
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from constructor', {retain: false, qos: 0});
expect(mkdirSyncSpy).toHaveBeenCalledWith(extensionPath);
});
it('Do not load corrupted extensions', async () => {
const extensionCode = 'definetly not a correct javascript code';
controller = new Controller(jest.fn(), jest.fn());
await controller.start();
await flushPromises();
mockMQTT.publishAsync.mockClear();
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: 'foo.js', code: extensionCode}));
await flushPromises();
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/response/extension/save', expect.any(String), {retain: false, qos: 0});
const payload = JSON.parse(mockMQTT.publishAsync.mock.calls[0][1]);
expect(payload).toEqual(expect.objectContaining({data: {}, status: 'error'}));
expect(payload.error).toMatch('Unexpected identifier');
});
it('Removes user extension', async () => {
const extensionPath = path.join(data.mockDir, 'extension');
const extensionCode = fs.readFileSync(path.join(__dirname, '..', 'assets', 'exampleExtension.js'), 'utf-8');
fs.mkdirSync(extensionPath);
const extensionFilePath = path.join(extensionPath, 'exampleExtension.js');
fs.copyFileSync(path.join(__dirname, '..', 'assets', 'exampleExtension.js'), extensionFilePath);
controller = new Controller(jest.fn(), jest.fn());
await controller.start();
await flushPromises();
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'test', {retain: false, qos: 0});
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/extensions',
stringify([{name: 'exampleExtension.js', code: extensionCode}]),
{retain: true, qos: 0},
);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: 'exampleExtension.js'}));
await flushPromises();
expect(unlinkSyncSpy).toHaveBeenCalledWith(extensionFilePath);
mockMQTT.publishAsync.mockClear();
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: 'non existing.js'}));
await flushPromises();
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/extension/remove',
stringify({data: {}, status: 'error', error: "Extension non existing.js doesn't exists"}),
{retain: false, qos: 0},
);
});
});
+169
View File
@@ -0,0 +1,169 @@
import * as data from '../mocks/data';
import {mockLogger} from '../mocks/logger';
import {mockMQTT, events as mockMQTTEvents} from '../mocks/mqtt';
import {flushPromises} from '../mocks/utils';
import {devices, mockController as mockZHController, returnDevices} from '../mocks/zigbeeHerdsman';
import fs from 'fs';
import path from 'path';
import stringify from 'json-stable-stringify-without-jsonify';
import {Controller} from '../../lib/controller';
import * as settings from '../../lib/util/settings';
const BASE_DIR = 'external_extensions';
describe('Extension: ExternalExtensions', () => {
let controller: Controller;
const mockBasePath = path.join(data.mockDir, BASE_DIR);
const existsSyncSpy = jest.spyOn(fs, 'existsSync');
const readdirSyncSpy = jest.spyOn(fs, 'readdirSync');
const mkdirSyncSpy = jest.spyOn(fs, 'mkdirSync');
const rmSyncSpy = jest.spyOn(fs, 'rmSync');
const writeFileSyncSpy = jest.spyOn(fs, 'writeFileSync');
const mocksClear = [
mockMQTT.endAsync,
mockMQTT.publishAsync,
mockLogger.debug,
mockLogger.error,
mockZHController.stop,
devices.bulb.save,
existsSyncSpy,
readdirSyncSpy,
mkdirSyncSpy,
rmSyncSpy,
writeFileSyncSpy,
];
const useAssets = (): void => {
fs.cpSync(path.join(__dirname, '..', 'assets', BASE_DIR), mockBasePath, {recursive: true});
};
const getFileCode = (fileName: string): string => {
return fs.readFileSync(path.join(__dirname, '..', 'assets', BASE_DIR, fileName), 'utf8');
};
beforeAll(async () => {
jest.useFakeTimers();
});
afterAll(async () => {
jest.useRealTimers();
});
beforeEach(async () => {
mocksClear.forEach((m) => m.mockClear());
data.writeDefaultConfiguration();
data.writeDefaultState();
settings.reRead();
returnDevices.splice(0);
controller = new Controller(jest.fn(), jest.fn());
});
afterEach(() => {
fs.rmSync(mockBasePath, {recursive: true, force: true});
});
it('loads nothing from folder', async () => {
await controller.start();
await flushPromises();
expect(existsSyncSpy).toHaveBeenCalledWith(mockBasePath);
expect(readdirSyncSpy).not.toHaveBeenCalledWith(mockBasePath);
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/extensions', stringify([]), {retain: true, qos: 0});
});
it('loads from folder', async () => {
useAssets();
await controller.start();
await flushPromises();
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/example2/extension', 'call2 from constructor', {retain: false, qos: 0});
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/example2/extension', 'call2 from start', {retain: false, qos: 0});
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from constructor', {retain: false, qos: 0});
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from start', {retain: false, qos: 0});
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/extensions',
stringify([
{name: 'example2Extension.js', code: getFileCode('example2Extension.js')},
{name: 'exampleExtension.js', code: getFileCode('exampleExtension.js')},
]),
{retain: true, qos: 0},
);
});
it('saves and removes from MQTT', async () => {
const extensionName = 'foo.js';
const extensionCode = getFileCode('exampleExtension.js');
const extensionFilePath = path.join(mockBasePath, extensionName);
await controller.start();
await flushPromises();
mocksClear.forEach((m) => m.mockClear());
//-- SAVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: extensionName, code: extensionCode}));
await flushPromises();
expect(mkdirSyncSpy).toHaveBeenCalledWith(mockBasePath, {recursive: true});
expect(writeFileSyncSpy).toHaveBeenCalledWith(extensionFilePath, extensionCode, 'utf8');
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from constructor', {retain: false, qos: 0});
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from start', {retain: false, qos: 0});
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/extensions', stringify([{name: extensionName, code: extensionCode}]), {
retain: true,
qos: 0,
});
//-- REMOVE
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: extensionName}));
await flushPromises();
expect(rmSyncSpy).toHaveBeenCalledWith(extensionFilePath, {force: true});
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from stop', {retain: false, qos: 0});
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/extensions', stringify([]), {retain: true, qos: 0});
});
it('returns error on invalid code', async () => {
const extensionName = 'foo.js';
const extensionCode = 'definetly not a correct javascript code';
const extensionFilePath = path.join(mockBasePath, extensionName);
await controller.start();
await flushPromises();
mocksClear.forEach((m) => m.mockClear());
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: extensionName, code: extensionCode}));
await flushPromises();
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/extension/save',
expect.stringContaining(`"error":"${extensionName} contains invalid code`),
{retain: false, qos: 0},
);
expect(writeFileSyncSpy).not.toHaveBeenCalledWith(extensionFilePath, extensionCode, 'utf8');
});
it('returns error on invalid removal', async () => {
const converterName = 'invalid.js';
const converterFilePath = path.join(mockBasePath, converterName);
await controller.start();
await flushPromises();
mocksClear.forEach((m) => m.mockClear());
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: converterName}));
await flushPromises();
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/extension/remove',
stringify({data: {}, status: 'error', error: `${converterName} (${converterFilePath}) doesn't exists`}),
{retain: false, qos: 0},
);
expect(rmSyncSpy).not.toHaveBeenCalledWith(converterFilePath, {force: true});
});
});
+6 -2
View File
@@ -102,8 +102,11 @@ describe('Extension: NetworkMap', () => {
data.writeDefaultConfiguration();
settings.reRead();
data.writeEmptyState();
fs.copyFileSync(path.join(__dirname, '..', 'assets', 'mock-external-converter.js'), path.join(data.mockDir, 'mock-external-converter.js'));
settings.set(['external_converters'], ['mock-external-converter.js']);
fs.mkdirSync(path.join(data.mockDir, 'external_converters'));
fs.copyFileSync(
path.join(__dirname, '..', 'assets', 'external_converters', 'mock-external-converter.js'),
path.join(data.mockDir, 'external_converters', 'mock-external-converter.js'),
);
controller = new Controller(jest.fn(), jest.fn());
await controller.start();
});
@@ -120,6 +123,7 @@ describe('Extension: NetworkMap', () => {
afterAll(async () => {
mockSleep.restore();
fs.rmSync(path.join(data.mockDir, 'external_converters'), {recursive: true});
jest.useRealTimers();
});
-1
View File
@@ -234,7 +234,6 @@ export function writeDefaultConfiguration(config: unknown = undefined): void {
friendly_name: 'ha_discovery_group',
},
},
external_converters: [],
};
yaml.writeIfChanged(path.join(mockDir, 'configuration.yaml'), config);
+2 -3
View File
@@ -15,7 +15,6 @@ const devicesFile2 = mockedData.joinPath('devices2.yaml');
const groupsFile = mockedData.joinPath('groups.yaml');
const secretFile = mockedData.joinPath('secret.yaml');
const minimalConfig = {
external_converters: [],
homeassistant: true,
mqtt: {base_topic: 'zigbee2mqtt', server: 'localhost'},
};
@@ -64,13 +63,13 @@ describe('Settings', () => {
});
it('Should return settings', () => {
write(configurationFile, {external_converters: ['abcd.js']});
write(configurationFile, {serial: {disable_led: true}});
const s = settings.get();
// @ts-expect-error workaround
const expected = objectAssignDeep.noMutate({}, settings.testing.defaults);
expected.devices = {};
expected.groups = {};
expected.external_converters = ['abcd.js'];
expected.serial = {disable_led: true};
expect(s).toStrictEqual(expected);
});