diff --git a/lib/extension/externalConverters.ts b/lib/extension/externalConverters.ts index e281349d3e..fe92f7844a 100644 --- a/lib/extension/externalConverters.ts +++ b/lib/extension/externalConverters.ts @@ -1,13 +1,13 @@ -import type * as zhc from 'zigbee-herdsman-converters'; +import type {ExternalDefinitionWithExtend} from 'zigbee-herdsman-converters'; import {addExternalDefinition, removeExternalDefinitions} from 'zigbee-herdsman-converters'; import logger from '../util/logger'; import ExternalJSExtension from './externalJS'; -type ModuleExports = zhc.ExternalDefinitionWithExtend | zhc.ExternalDefinitionWithExtend[]; +type TModule = ExternalDefinitionWithExtend | ExternalDefinitionWithExtend[]; -export default class ExternalConverters extends ExternalJSExtension { +export default class ExternalConverters extends ExternalJSExtension { constructor( zigbee: Zigbee, mqtt: MQTT, @@ -33,27 +33,29 @@ export default class ExternalConverters extends ExternalJSExtension { + protected async removeJS(name: string, mod: TModule): Promise { removeExternalDefinitions(name); await this.zigbee.resolveDevicesDefinitions(true); } - protected async loadJS(name: string, module: ModuleExports): Promise { + protected async loadJS(name: string, mod: TModule, newName?: string): Promise { try { removeExternalDefinitions(name); - const definitions = Array.isArray(module) ? module : [module]; + const definitions = Array.isArray(mod) ? mod : [mod]; + for (const definition of definitions) { - definition.externalConverterName = name; + definition.externalConverterName = newName ?? name; addExternalDefinition(definition); - logger.info(`Loaded external converter '${name}'.`); + logger.info(`Loaded external converter '${newName ?? name}'.`); } await this.zigbee.resolveDevicesDefinitions(true); } catch (error) { - logger.error(`Failed to load external converter '${name}'`); + /* v8 ignore next */ + logger.error(`Failed to load external converter '${newName ?? 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`, diff --git a/lib/extension/externalExtensions.ts b/lib/extension/externalExtensions.ts index 3537617b34..e5baedba59 100644 --- a/lib/extension/externalExtensions.ts +++ b/lib/extension/externalExtensions.ts @@ -4,9 +4,9 @@ import logger from '../util/logger'; import * as settings from '../util/settings'; import ExternalJSExtension from './externalJS'; -type ModuleExports = typeof Extension; +type TModule = new (...args: ConstructorParameters) => Extension; -export default class ExternalExtensions extends ExternalJSExtension { +export default class ExternalExtensions extends ExternalJSExtension { constructor( zigbee: Zigbee, mqtt: MQTT, @@ -31,16 +31,15 @@ export default class ExternalExtensions extends ExternalJSExtension { - await this.enableDisableExtension(false, module.name); + protected async removeJS(name: string, mod: TModule): Promise { + await this.enableDisableExtension(false, mod.name); } - protected async loadJS(name: string, module: ModuleExports): Promise { + protected async loadJS(name: string, mod: TModule, newName?: string): Promise { // stop if already started - await this.enableDisableExtension(false, module.name); + await this.enableDisableExtension(false, mod.name); await this.addExtension( - // @ts-expect-error `module` is the interface, not the actual passed class - new module( + new mod( this.zigbee, this.mqtt, this.state, @@ -49,11 +48,13 @@ export default class ExternalExtensions extends ExternalJSExtension extends Extension { } for (const fileName of fs.readdirSync(this.basePath)) { - if (fileName.endsWith('.js')) { + if (fileName.endsWith('.js') || fileName.endsWith('.cjs') || fileName.endsWith('.mjs')) { yield {name: fileName, code: this.getFileCode(fileName)}; } } @@ -100,9 +99,9 @@ export default abstract class ExternalJSExtension extends Extension { } } - protected abstract removeJS(name: string, module: M): Promise; + protected abstract removeJS(name: string, mod: M): Promise; - protected abstract loadJS(name: string, module: M): Promise; + protected abstract loadJS(name: string, mod: M, newName?: string): Promise; @bind private async remove( message: Zigbee2MQTTAPI['bridge/request/converter/remove'] | Zigbee2MQTTAPI['bridge/request/extension/remove'], @@ -115,8 +114,9 @@ export default abstract class ExternalJSExtension extends Extension { const toBeRemoved = this.getFilePath(name); if (fs.existsSync(toBeRemoved)) { - await this.removeJS(name, this.loadModuleFromText(this.getFileCode(name), name)); + const mod = await import(toBeRemoved); + await this.removeJS(name, mod.default); fs.rmSync(toBeRemoved, {force: true}); logger.info(`${name} (${toBeRemoved}) removed.`); await this.publishExternalJS(); @@ -135,25 +135,47 @@ export default abstract class ExternalJSExtension extends Extension { } const {name, code} = message; + const filePath = this.getFilePath(name, true); + let newFilePath = filePath; + let newName = name; + + if (fs.existsSync(filePath)) { + // if file already exist, version it to bypass node module caching + const versionMatch = name.match(/\.(\d+)\.(c|m)?js$/); + + if (versionMatch) { + const version = parseInt(versionMatch[1], 10); + newName = name.replace(`.${version}.`, `.${version + 1}.`); + } else { + const ext = path.extname(name); + newName = name.replace(ext, `.1${ext}`); + } + + newFilePath = this.getFilePath(newName, true); + } try { - await this.loadJS(name, this.loadModuleFromText(code, name)); + fs.writeFileSync(newFilePath, code, 'utf8'); - const filePath = this.getFilePath(name, true); + const mod = await import(newFilePath); - fs.writeFileSync(filePath, code, 'utf8'); - logger.info(`${name} loaded. Contents written to '${filePath}'.`); + await this.loadJS(name, mod.default, newName); + logger.info(`${newName} loaded. Contents written to '${newFilePath}'.`); await this.publishExternalJS(); return utils.getResponse(message, {}); } catch (error) { - return utils.getResponse(message, {}, `${name} contains invalid code: ${(error as Error).message}`); + fs.rmSync(newFilePath, {force: true}); + + return utils.getResponse(message, {}, `${newName} contains invalid code: ${(error as Error).message}`); } } private async loadFiles(): Promise { for (const extension of this.getFiles()) { - await this.loadJS(extension.name, this.loadModuleFromText(extension.code, extension.name)); + const mod = await import(path.join(this.basePath, extension.name)); + + await this.loadJS(extension.name, mod.default); } } @@ -169,23 +191,4 @@ export default abstract class ExternalJSExtension extends Extension { 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; - } } diff --git a/test/assets/external_converters/mock-external-converter-multiple.js b/test/assets/external_converters/cjs/mock-external-converter-multiple.js similarity index 100% rename from test/assets/external_converters/mock-external-converter-multiple.js rename to test/assets/external_converters/cjs/mock-external-converter-multiple.js diff --git a/test/assets/external_converters/mock-external-converter.js b/test/assets/external_converters/cjs/mock-external-converter.js similarity index 71% rename from test/assets/external_converters/mock-external-converter.js rename to test/assets/external_converters/cjs/mock-external-converter.js index c2f0437721..d90b52c0b9 100644 --- a/test/assets/external_converters/mock-external-converter.js +++ b/test/assets/external_converters/cjs/mock-external-converter.js @@ -1,9 +1,11 @@ +const {posix} = require('node:path'); + const mockDevice = { mock: true, zigbeeModel: ['external_converter_device'], vendor: 'external', model: 'external_converter_device', - description: 'external', + description: posix.join('external', 'converter'), fromZigbee: [], toZigbee: [], exposes: [], diff --git a/test/assets/external_converters/mjs/mock-external-converter-multiple.mjs b/test/assets/external_converters/mjs/mock-external-converter-multiple.mjs new file mode 100644 index 0000000000..4e71c681fa --- /dev/null +++ b/test/assets/external_converters/mjs/mock-external-converter-multiple.mjs @@ -0,0 +1,22 @@ +export default [ + { + mock: 1, + model: 'external_converters_device_1', + zigbeeModel: ['external_converter_device_1'], + vendor: 'external_1', + description: 'external_1', + fromZigbee: [], + toZigbee: [], + exposes: [], + }, + { + mock: 2, + model: 'external_converters_device_2', + zigbeeModel: ['external_converter_device_2'], + vendor: 'external_2', + description: 'external_2', + fromZigbee: [], + toZigbee: [], + exposes: [], + }, +]; diff --git a/test/assets/external_converters/mjs/mock-external-converter.mjs b/test/assets/external_converters/mjs/mock-external-converter.mjs new file mode 100644 index 0000000000..099e9c62a0 --- /dev/null +++ b/test/assets/external_converters/mjs/mock-external-converter.mjs @@ -0,0 +1,12 @@ +import {posix} from 'node:path'; + +export default { + mock: true, + zigbeeModel: ['external_converter_device'], + vendor: 'external', + model: 'external_converter_device', + description: posix.join('external', 'converter'), + fromZigbee: [], + toZigbee: [], + exposes: [], +}; diff --git a/test/assets/external_extensions/example2Extension.js b/test/assets/external_extensions/cjs/example2Extension.js similarity index 100% rename from test/assets/external_extensions/example2Extension.js rename to test/assets/external_extensions/cjs/example2Extension.js diff --git a/test/assets/external_extensions/exampleExtension.js b/test/assets/external_extensions/cjs/exampleExtension.js similarity index 100% rename from test/assets/external_extensions/exampleExtension.js rename to test/assets/external_extensions/cjs/exampleExtension.js diff --git a/test/assets/external_extensions/mjs/example2Extension.mjs b/test/assets/external_extensions/mjs/example2Extension.mjs new file mode 100644 index 0000000000..4ac629db47 --- /dev/null +++ b/test/assets/external_extensions/mjs/example2Extension.mjs @@ -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'); + } +} + +export default Example2; diff --git a/test/assets/external_extensions/mjs/exampleExtension.mjs b/test/assets/external_extensions/mjs/exampleExtension.mjs new file mode 100644 index 0000000000..40e373f78f --- /dev/null +++ b/test/assets/external_extensions/mjs/exampleExtension.mjs @@ -0,0 +1,16 @@ +class Example { + constructor(zigbee, mqtt, state, publishEntityState, eventBus) { + this.mqtt = mqtt; + this.mqtt.publish('example/extension', 'call from constructor'); + } + + start() { + this.mqtt.publish('example/extension', 'call from start'); + } + + stop() { + this.mqtt.publish('example/extension', 'call from stop'); + } +} + +export default Example; diff --git a/test/extensions/externalConverters.test.ts b/test/extensions/externalConverters.test.ts index f92bc8ef1d..adeb8b24df 100644 --- a/test/extensions/externalConverters.test.ts +++ b/test/extensions/externalConverters.test.ts @@ -1,9 +1,10 @@ import * as data from '../mocks/data'; import {mockLogger} from '../mocks/logger'; -import {mockMQTTEndAsync, events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt'; +import {mockMQTTEndAsync, mockMQTTPublishAsync} from '../mocks/mqtt'; import {flushPromises} from '../mocks/utils'; import {devices, mockController as mockZHController, returnDevices} from '../mocks/zigbeeHerdsman'; +import type ExternalConverters from '../../lib/extension/externalConverters'; import type Device from '../../lib/model/device'; import fs from 'node:fs'; @@ -47,12 +48,17 @@ describe('Extension: ExternalConverters', () => { zhcRemoveExternalDefinitionsSpy, ]; - const useAssets = (): void => { - fs.cpSync(path.join(__dirname, '..', 'assets', BASE_DIR), mockBasePath, {recursive: true}); + const getExtension = (): ExternalConverters => { + // @ts-expect-error private + return controller.extensions.find((e) => e.constructor.name === 'ExternalConverters'); + }; + + const useAssets = (mtype: 'cjs' | 'mjs'): void => { + fs.cpSync(path.join(__dirname, '..', 'assets', BASE_DIR, mtype), mockBasePath, {recursive: true}); }; - const getFileCode = (fileName: string): string => { - return fs.readFileSync(path.join(__dirname, '..', 'assets', BASE_DIR, fileName), 'utf8'); + const getFileCode = (mtype: 'cjs' | 'mjs', fileName: string): string => { + return fs.readFileSync(path.join(__dirname, '..', 'assets', BASE_DIR, mtype, fileName), 'utf8'); }; const getZ2MDevice = (zhDevice: unknown): Device => { @@ -79,6 +85,8 @@ describe('Extension: ExternalConverters', () => { beforeEach(async () => { zhc.removeExternalDefinitions(); // remove all external converters + // @ts-expect-error private - clear cached + await controller.zigbee.resolveDevicesDefinitions(true); mocksClear.forEach((m) => m.mockClear()); data.writeDefaultConfiguration(); data.writeDefaultState(); @@ -106,14 +114,14 @@ describe('Extension: ExternalConverters', () => { expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/converters', stringify([]), {retain: true, qos: 0}); }); - it('loads converters', async () => { - useAssets(); + it('CJS: loads converters', async () => { + useAssets('cjs'); await controller.start(); await flushPromises(); expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({ - description: 'external', + description: 'external/converter', model: 'external_converter_device', vendor: 'external', zigbeeModel: ['external_converter_device'], @@ -121,8 +129,8 @@ describe('Extension: ExternalConverters', () => { expect(mockMQTTPublishAsync).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')}, + {name: 'mock-external-converter-multiple.js', code: getFileCode('cjs', 'mock-external-converter-multiple.js')}, + {name: 'mock-external-converter.js', code: getFileCode('cjs', 'mock-external-converter.js')}, ]), {retain: true, qos: 0}, ); @@ -156,7 +164,7 @@ describe('Extension: ExternalConverters', () => { zigbeeModel: ['external_converter_device'], vendor: 'external', model: 'external_converter_device', - description: 'external', + description: 'external/converter', }), ); @@ -168,7 +176,7 @@ describe('Extension: ExternalConverters', () => { model_id: 'external_converter_device', supported: true, definition: expect.objectContaining({ - description: 'external', + description: 'external/converter', model: 'external_converter_device', }), }), @@ -176,13 +184,156 @@ describe('Extension: ExternalConverters', () => { ); }); - it('saves and removes from MQTT', async () => { - const converterName = 'foo.js'; - const converterCode = getFileCode('mock-external-converter.js'); - const converterFilePath = path.join(mockBasePath, converterName); + it('MJS: loads converters', async () => { + useAssets('mjs'); + + await controller.start(); + await flushPromises(); + + expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({ + description: 'external/converter', + model: 'external_converter_device', + vendor: 'external', + zigbeeModel: ['external_converter_device'], + }); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/converters', + stringify([ + {name: 'mock-external-converter-multiple.mjs', code: getFileCode('mjs', 'mock-external-converter-multiple.mjs')}, + {name: 'mock-external-converter.mjs', code: getFileCode('mjs', 'mock-external-converter.mjs')}, + ]), + {retain: true, qos: 0}, + ); + expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2); + expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(1, 'mock-external-converter-multiple.mjs'); + expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, 'mock-external-converter.mjs'); + expect(zhcAddExternalDefinitionSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + mock: 1, + model: 'external_converters_device_1', + zigbeeModel: ['external_converter_device_1'], + vendor: 'external_1', + description: 'external_1', + }), + ); + expect(zhcAddExternalDefinitionSpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + mock: 2, + model: 'external_converters_device_2', + zigbeeModel: ['external_converter_device_2'], + vendor: 'external_2', + description: 'external_2', + }), + ); + expect(zhcAddExternalDefinitionSpy).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + mock: true, + zigbeeModel: ['external_converter_device'], + vendor: 'external', + model: 'external_converter_device', + description: 'external/converter', + }), + ); + + const bridgeDevices = mockMQTTPublishAsync.mock.calls.filter((c) => c[0] === 'zigbee2mqtt/bridge/devices'); + expect(bridgeDevices.length).toBe(1); + expect(JSON.parse(bridgeDevices[0][1])).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + model_id: 'external_converter_device', + supported: true, + definition: expect.objectContaining({ + description: 'external/converter', + model: 'external_converter_device', + }), + }), + ]), + ); + }); + + it('updates after edit from MQTT', async () => { + const converterName = 'mock-external-converter.js'; + let converterCode = getFileCode('cjs', 'mock-external-converter.js'); + useAssets('cjs'); await controller.start(); await flushPromises(); + + expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({ + description: 'external/converter', + model: 'external_converter_device', + vendor: 'external', + zigbeeModel: ['external_converter_device'], + }); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/converters', + stringify([ + {name: 'mock-external-converter-multiple.js', code: getFileCode('cjs', 'mock-external-converter-multiple.js')}, + {name: converterName, code: getFileCode('cjs', converterName)}, + ]), + {retain: true, qos: 0}, + ); + + converterCode = converterCode.replace("posix.join('external', 'converter')", "posix.join('external', 'converter', 'edited')"); + + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/converter/save', + message: {name: converterName, code: converterCode}, + }); + + expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({ + description: 'external/converter/edited', + model: 'external_converter_device', + vendor: 'external', + zigbeeModel: ['external_converter_device'], + }); + expect(zhcAddExternalDefinitionSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + mock: true, + zigbeeModel: ['external_converter_device'], + vendor: 'external', + model: 'external_converter_device', + description: 'external/converter/edited', + externalConverterName: 'mock-external-converter.1.js', + }), + ); + + converterCode = converterCode.replace("posix.join('external', 'converter', 'edited')", "posix.join('external', 'converter')"); + + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/converter/save', + message: {name: 'mock-external-converter.1.js', code: converterCode}, + }); + + expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({ + description: 'external/converter', + model: 'external_converter_device', + vendor: 'external', + zigbeeModel: ['external_converter_device'], + }); + expect(zhcAddExternalDefinitionSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + mock: true, + zigbeeModel: ['external_converter_device'], + vendor: 'external', + model: 'external_converter_device', + description: 'external/converter', + externalConverterName: 'mock-external-converter.2.js', + }), + ); + }); + }); + + describe('from MQTT', () => { + it('CJS: saves and removes', async () => { + const converterName = 'foo.js'; + const converterCode = getFileCode('cjs', 'mock-external-converter.js'); + const converterFilePath = path.join(mockBasePath, converterName); + + await resetExtension(); mocksClear.forEach((m) => m.mockClear()); expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({ @@ -193,11 +344,15 @@ describe('Extension: ExternalConverters', () => { }); //-- SAVE - mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode})); - await flushPromises(); + // await mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode})); + // await flushPromises(); + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/converter/save', + message: {name: converterName, code: converterCode}, + }); expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({ - description: 'external', + description: 'external/converter', model: 'external_converter_device', vendor: 'external', zigbeeModel: ['external_converter_device'], @@ -212,7 +367,7 @@ describe('Extension: ExternalConverters', () => { zigbeeModel: ['external_converter_device'], vendor: 'external', model: 'external_converter_device', - description: 'external', + description: 'external/converter', }), ); expect(mockMQTTPublishAsync).toHaveBeenCalledWith( @@ -225,8 +380,12 @@ describe('Extension: ExternalConverters', () => { ); //-- REMOVE - mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName})); - await flushPromises(); + // await mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName})); + // await flushPromises(); + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/converter/remove', + message: {name: converterName}, + }); expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({ description: 'Automatically generated definition', @@ -239,119 +398,220 @@ describe('Extension: ExternalConverters', () => { expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, converterName); expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/converters', stringify([]), {retain: true, qos: 0}); }); - }); - - 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 resetExtension(); - mocksClear.forEach((m) => m.mockClear()); + it('MJS: saves and removes', async () => { + const converterName = 'foo.mjs'; + const converterCode = getFileCode('mjs', 'mock-external-converter.mjs'); + const converterFilePath = path.join(mockBasePath, converterName); - mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode})); - await flushPromises(); + await resetExtension(); + mocksClear.forEach((m) => m.mockClear()); - expect(mockMQTTPublishAsync).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'); - }); + expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({ + description: 'Automatically generated definition', + model: 'external_converter_device', + vendor: '', + zigbeeModel: ['external_converter_device'], + }); - it('returns error on invalid removal', async () => { - const converterName = 'invalid.js'; - const converterFilePath = path.join(mockBasePath, converterName); + //-- SAVE + // await mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode})); + // await flushPromises(); + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/converter/save', + message: {name: converterName, code: converterCode}, + }); - await resetExtension(); - mocksClear.forEach((m) => m.mockClear()); + expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({ + description: 'external/converter', + 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(zhcAddExternalDefinitionSpy).toHaveBeenCalledWith( + expect.objectContaining({ + mock: true, + zigbeeModel: ['external_converter_device'], + vendor: 'external', + model: 'external_converter_device', + description: 'external/converter', + }), + ); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/converters', + stringify([{name: converterName, code: converterCode}]), + { + retain: true, + qos: 0, + }, + ); - mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName})); - await flushPromises(); + //-- REMOVE + // await mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName})); + // await flushPromises(); + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/converter/remove', + message: {name: converterName}, + }); - expect(mockMQTTPublishAsync).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}); - }); + 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(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/converters', stringify([]), {retain: true, qos: 0}); + }); - it('returns error on invalid definition', async () => { - const converterName = 'foo.js'; - const converterCode = getFileCode('mock-external-converter.js'); - const converterFilePath = path.join(mockBasePath, converterName); + it('returns error on invalid code', async () => { + const converterName = 'foo1.js'; + const converterCode = 'definetly not a correct javascript code'; + const converterFilePath = path.join(mockBasePath, converterName); - await resetExtension(); - mocksClear.forEach((m) => m.mockClear()); + await resetExtension(); + mocksClear.forEach((m) => m.mockClear()); - const errorMsg = `Invalid definition`; + // await mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode})); + // await flushPromises(); + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/converter/save', + message: {name: converterName, code: converterCode}, + }); - zhcAddExternalDefinitionSpy.mockImplementationOnce(() => { - throw new Error(errorMsg); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/converter/save', + expect.stringContaining(`"error":"${converterName} contains invalid code`), + {retain: false, qos: 0}, + ); + expect(writeFileSyncSpy).toHaveBeenCalledWith(converterFilePath, converterCode, 'utf8'); + expect(rmSyncSpy).toHaveBeenCalledWith(converterFilePath, {force: true}); }); - mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode})); - await flushPromises(); + it('returns error on invalid removal', async () => { + const converterName = 'foo2.js'; + const converterFilePath = path.join(mockBasePath, converterName); - expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/response/converter/save', expect.stringContaining(errorMsg), { - retain: false, - qos: 0, + await resetExtension(); + mocksClear.forEach((m) => m.mockClear()); + + // await mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName})); + // await flushPromises(); + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/converter/remove', + message: {name: converterName}, + }); + + expect(mockMQTTPublishAsync).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}); }); - 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); + it('returns error on invalid definition', async () => { + const converterName = 'foo3.js'; + const converterCode = getFileCode('cjs', 'mock-external-converter.js'); + const converterFilePath = path.join(mockBasePath, converterName); - await resetExtension(); - mocksClear.forEach((m) => m.mockClear()); + await resetExtension(); + mocksClear.forEach((m) => m.mockClear()); - //-- SAVE - mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode})); - await flushPromises(); + const errorMsg = `Invalid definition`; + + zhcAddExternalDefinitionSpy.mockImplementationOnce(() => { + throw new Error(errorMsg); + }); - const errorMsg = `Failed to remove definition`; + // await mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode})); + // await flushPromises(); + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/converter/save', + message: {name: converterName, code: converterCode}, + }); - zhcRemoveExternalDefinitionsSpy.mockImplementationOnce(() => { - throw new Error(errorMsg); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/response/converter/save', expect.stringContaining(errorMsg), { + retain: false, + qos: 0, + }); + expect(writeFileSyncSpy).toHaveBeenCalledWith(converterFilePath, converterCode, 'utf8'); + expect(rmSyncSpy).toHaveBeenCalledWith(converterFilePath, {force: true}); }); - //-- REMOVE - mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName})); - await flushPromises(); + it('returns error on failed removal', async () => { + const converterName = 'foo4.js'; + const converterCode = getFileCode('cjs', 'mock-external-converter.js'); + const converterFilePath = path.join(mockBasePath, converterName); - expect(mockMQTTPublishAsync).toHaveBeenCalledWith( - 'zigbee2mqtt/bridge/response/converter/remove', - stringify({data: {}, status: 'error', error: errorMsg}), - {retain: false, qos: 0}, - ); - expect(rmSyncSpy).not.toHaveBeenCalledWith(converterFilePath, {force: true}); - }); + await resetExtension(); + mocksClear.forEach((m) => m.mockClear()); - it('handles invalid payloads', async () => { - await resetExtension(); - mocksClear.forEach((m) => m.mockClear()); + //-- SAVE + // await mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode})); + // await flushPromises(); + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/converter/save', + message: {name: converterName, code: converterCode}, + }); - mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: 'test.js', transaction: 1 /* code */})); - await flushPromises(); + const errorMsg = `Failed to remove definition`; - expect(mockMQTTPublishAsync).toHaveBeenCalledWith( - 'zigbee2mqtt/bridge/response/converter/save', - stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 1}), - {retain: false, qos: 0}, - ); + zhcRemoveExternalDefinitionsSpy.mockImplementationOnce(() => { + throw new Error(errorMsg); + }); - mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({namex: 'test.js', transaction: 2})); - await flushPromises(); + //-- REMOVE + // await mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName})); + // await flushPromises(); + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/converter/remove', + message: {name: converterName}, + }); + + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/converter/remove', + stringify({data: {}, status: 'error', error: errorMsg}), + {retain: false, qos: 0}, + ); + expect(rmSyncSpy).not.toHaveBeenCalledWith(converterFilePath, {force: true}); + }); + + it('handles invalid payloads', async () => { + await resetExtension(); + mocksClear.forEach((m) => m.mockClear()); + + // await mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: 'foo5.js', transaction: 1 /* code */})); + // await flushPromises(); + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/converter/save', + message: {name: 'foo5.js', transaction: 1 /* code */}, + }); + + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/converter/save', + stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 1}), + {retain: false, qos: 0}, + ); + + // await mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({namex: 'foo5.js', transaction: 2})); + // await flushPromises(); + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/converter/remove', + message: {namex: 'foo5.js', transaction: 2}, + }); - expect(mockMQTTPublishAsync).toHaveBeenCalledWith( - 'zigbee2mqtt/bridge/response/converter/remove', - stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 2}), - {retain: false, qos: 0}, - ); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/converter/remove', + stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 2}), + {retain: false, qos: 0}, + ); + }); }); }); diff --git a/test/extensions/externalExtensions.test.ts b/test/extensions/externalExtensions.test.ts index f6a5463025..2739f6a0c9 100644 --- a/test/extensions/externalExtensions.test.ts +++ b/test/extensions/externalExtensions.test.ts @@ -1,6 +1,6 @@ import * as data from '../mocks/data'; import {mockLogger} from '../mocks/logger'; -import {mockMQTTEndAsync, events as mockMQTTEvents, mockMQTTPublishAsync} from '../mocks/mqtt'; +import {mockMQTTEndAsync, mockMQTTPublishAsync} from '../mocks/mqtt'; import {flushPromises} from '../mocks/utils'; import {devices, mockController as mockZHController, returnDevices} from '../mocks/zigbeeHerdsman'; @@ -10,6 +10,7 @@ import path from 'node:path'; import stringify from 'json-stable-stringify-without-jsonify'; import {Controller} from '../../lib/controller'; +import ExternalExtensions from '../../lib/extension/externalExtensions'; import * as settings from '../../lib/util/settings'; const BASE_DIR = 'external_extensions'; @@ -38,12 +39,17 @@ describe('Extension: ExternalExtensions', () => { writeFileSyncSpy, ]; - const useAssets = (): void => { - fs.cpSync(path.join(__dirname, '..', 'assets', BASE_DIR), mockBasePath, {recursive: true}); + const getExtension = (): ExternalExtensions => { + // @ts-expect-error private + return controller.extensions.find((e) => e.constructor.name === 'ExternalExtensions'); }; - const getFileCode = (fileName: string): string => { - return fs.readFileSync(path.join(__dirname, '..', 'assets', BASE_DIR, fileName), 'utf8'); + const useAssets = (mtype: 'cjs' | 'mjs'): void => { + fs.cpSync(path.join(__dirname, '..', 'assets', BASE_DIR, mtype), mockBasePath, {recursive: true}); + }; + + const getFileCode = (mtype: 'cjs' | 'mjs', fileName: string): string => { + return fs.readFileSync(path.join(__dirname, '..', 'assets', BASE_DIR, mtype, fileName), 'utf8'); }; const resetExtension = async (): Promise => { @@ -89,8 +95,8 @@ describe('Extension: ExternalExtensions', () => { expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/extensions', stringify([]), {retain: true, qos: 0}); }); - it('loads extensions', async () => { - useAssets(); + it('CJS: loads extensions', async () => { + useAssets('cjs'); await controller.start(); await flushPromises(); @@ -102,25 +108,92 @@ describe('Extension: ExternalExtensions', () => { expect(mockMQTTPublishAsync).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/extensions', stringify([ - {name: 'example2Extension.js', code: getFileCode('example2Extension.js')}, - {name: 'exampleExtension.js', code: getFileCode('exampleExtension.js')}, + {name: 'example2Extension.js', code: getFileCode('cjs', 'example2Extension.js')}, + {name: 'exampleExtension.js', code: getFileCode('cjs', '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); + it('MJS: loads extensions', async () => { + useAssets('mjs'); await controller.start(); await flushPromises(); + + expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example2/extension', 'call2 from constructor', {retain: false, qos: 0}); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example2/extension', 'call2 from start', {retain: false, qos: 0}); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from constructor', {retain: false, qos: 0}); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from start', {retain: false, qos: 0}); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/extensions', + stringify([ + {name: 'example2Extension.mjs', code: getFileCode('mjs', 'example2Extension.mjs')}, + {name: 'exampleExtension.mjs', code: getFileCode('mjs', 'exampleExtension.mjs')}, + ]), + {retain: true, qos: 0}, + ); + }); + + it('updates after edit from MQTT', async () => { + const extensionName = 'exampleExtension.js'; + let extensionCode = getFileCode('cjs', 'exampleExtension.js'); + + useAssets('cjs'); + await controller.start(); + await flushPromises(); + + expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example2/extension', 'call2 from constructor', {retain: false, qos: 0}); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example2/extension', 'call2 from start', {retain: false, qos: 0}); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from constructor', {retain: false, qos: 0}); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from start', {retain: false, qos: 0}); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/extensions', + stringify([ + {name: 'example2Extension.js', code: getFileCode('cjs', 'example2Extension.js')}, + {name: extensionName, code: getFileCode('cjs', extensionName)}, + ]), + {retain: true, qos: 0}, + ); + + extensionCode = extensionCode.replace("'call from start'", "'call from start - edited'"); + + mockMQTTPublishAsync.mockClear(); + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/extension/save', + message: {name: extensionName, code: extensionCode}, + }); + + expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from start - edited', {retain: false, qos: 0}); + + extensionCode = extensionCode.replace("'call from start - edited'", "'call from start'"); + + mockMQTTPublishAsync.mockClear(); + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/extension/save', + message: {name: 'exampleExtension.1.js', code: extensionCode}, + }); + + expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from start', {retain: false, qos: 0}); + }); + }); + + describe('from MQTT', () => { + it('CJS: saves and removes', async () => { + const extensionName = 'foo.js'; + const extensionCode = getFileCode('cjs', 'exampleExtension.js'); + const extensionFilePath = path.join(mockBasePath, extensionName); + + await resetExtension(); mocksClear.forEach((m) => m.mockClear()); //-- SAVE - mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: extensionName, code: extensionCode})); - await flushPromises(); + // await mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: extensionName, code: extensionCode})); + // await flushPromises(); + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/extension/save', + message: {name: extensionName, code: extensionCode}, + }); expect(mkdirSyncSpy).toHaveBeenCalledWith(mockBasePath, {recursive: true}); expect(writeFileSyncSpy).toHaveBeenCalledWith(extensionFilePath, extensionCode, 'utf8'); @@ -136,72 +209,135 @@ describe('Extension: ExternalExtensions', () => { ); //-- REMOVE - mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: extensionName})); - await flushPromises(); + // await mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: extensionName})); + // await flushPromises(); + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/extension/remove', + message: {name: extensionName}, + }); expect(rmSyncSpy).toHaveBeenCalledWith(extensionFilePath, {force: true}); expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from stop', {retain: false, qos: 0}); expect(mockMQTTPublishAsync).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); + it('MJS: saves and removes', async () => { + const extensionName = 'foo.mjs'; + const extensionCode = getFileCode('mjs', 'exampleExtension.mjs'); + const extensionFilePath = path.join(mockBasePath, extensionName); - await resetExtension(); - mocksClear.forEach((m) => m.mockClear()); + await resetExtension(); + mocksClear.forEach((m) => m.mockClear()); - mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: extensionName, code: extensionCode})); - await flushPromises(); + //-- SAVE + // await mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: extensionName, code: extensionCode})); + // await flushPromises(); + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/extension/save', + message: {name: extensionName, code: extensionCode}, + }); - expect(mockMQTTPublishAsync).toHaveBeenCalledWith( - 'zigbee2mqtt/bridge/response/extension/save', - expect.stringContaining(`"error":"${extensionName} contains invalid code`), - {retain: false, qos: 0}, - ); - expect(writeFileSyncSpy).not.toHaveBeenCalledWith(extensionFilePath, extensionCode, 'utf8'); - }); + expect(mkdirSyncSpy).toHaveBeenCalledWith(mockBasePath, {recursive: true}); + expect(writeFileSyncSpy).toHaveBeenCalledWith(extensionFilePath, extensionCode, 'utf8'); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from constructor', {retain: false, qos: 0}); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from start', {retain: false, qos: 0}); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/extensions', + stringify([{name: extensionName, code: extensionCode}]), + { + retain: true, + qos: 0, + }, + ); - it('returns error on invalid removal', async () => { - const converterName = 'invalid.js'; - const converterFilePath = path.join(mockBasePath, converterName); + //-- REMOVE + // await mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: extensionName})); + // await flushPromises(); + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/extension/remove', + message: {name: extensionName}, + }); - await resetExtension(); - mocksClear.forEach((m) => m.mockClear()); + expect(rmSyncSpy).toHaveBeenCalledWith(extensionFilePath, {force: true}); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from stop', {retain: false, qos: 0}); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/extensions', stringify([]), {retain: true, qos: 0}); + }); - mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: converterName})); - await flushPromises(); + it('returns error on invalid code', async () => { + const extensionName = 'foo1.js'; + const extensionCode = 'definetly not a correct javascript code'; + const extensionFilePath = path.join(mockBasePath, extensionName); - expect(mockMQTTPublishAsync).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}); - }); + await resetExtension(); + mocksClear.forEach((m) => m.mockClear()); - it('handles invalid payloads', async () => { - await resetExtension(); - mocksClear.forEach((m) => m.mockClear()); + // await mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: extensionName, code: extensionCode})); + // await flushPromises(); + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/extension/save', + message: {name: extensionName, code: extensionCode}, + }); - mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: 'test.js', transaction: 1 /* code */})); - await flushPromises(); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/extension/save', + expect.stringContaining(`"error":"${extensionName} contains invalid code`), + {retain: false, qos: 0}, + ); + expect(writeFileSyncSpy).toHaveBeenCalledWith(extensionFilePath, extensionCode, 'utf8'); + expect(rmSyncSpy).toHaveBeenCalledWith(extensionFilePath, {force: true}); + }); - expect(mockMQTTPublishAsync).toHaveBeenCalledWith( - 'zigbee2mqtt/bridge/response/extension/save', - stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 1}), - {retain: false, qos: 0}, - ); + it('returns error on invalid removal', async () => { + const extensionName = 'foo2.js'; + const extensionFilePath = path.join(mockBasePath, extensionName); - mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({namex: 'test.js', transaction: 2})); - await flushPromises(); + await resetExtension(); + mocksClear.forEach((m) => m.mockClear()); - expect(mockMQTTPublishAsync).toHaveBeenCalledWith( - 'zigbee2mqtt/bridge/response/extension/remove', - stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 2}), - {retain: false, qos: 0}, - ); + // await mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: converterName})); + // await flushPromises(); + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/extension/remove', + message: {name: extensionName}, + }); + + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/extension/remove', + stringify({data: {}, status: 'error', error: `${extensionName} (${extensionFilePath}) doesn't exists`}), + {retain: false, qos: 0}, + ); + expect(rmSyncSpy).not.toHaveBeenCalledWith(extensionFilePath, {force: true}); + }); + + it('handles invalid payloads', async () => { + await resetExtension(); + mocksClear.forEach((m) => m.mockClear()); + + // await mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: 'foo3.js', transaction: 1 /* code */})); + // await flushPromises(); + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/extension/save', + message: {name: 'foo3.js', transaction: 1 /* code */}, + }); + + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/extension/save', + stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 1}), + {retain: false, qos: 0}, + ); + + // await mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({namex: 'foo3.js', transaction: 2})); + // await flushPromises(); + await getExtension().onMQTTMessage({ + topic: 'zigbee2mqtt/bridge/request/extension/remove', + message: {namex: 'foo3.js', transaction: 2}, + }); + + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/extension/remove', + stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 2}), + {retain: false, qos: 0}, + ); + }); }); }); diff --git a/test/extensions/networkMap.test.ts b/test/extensions/networkMap.test.ts index ca20010bac..13f377015a 100644 --- a/test/extensions/networkMap.test.ts +++ b/test/extensions/networkMap.test.ts @@ -104,7 +104,7 @@ describe('Extension: NetworkMap', () => { data.writeEmptyState(); fs.mkdirSync(path.join(data.mockDir, 'external_converters')); fs.copyFileSync( - path.join(__dirname, '..', 'assets', 'external_converters', 'mock-external-converter.js'), + path.join(__dirname, '..', 'assets', 'external_converters', 'cjs', 'mock-external-converter.js'), path.join(data.mockDir, 'external_converters', 'mock-external-converter.js'), ); controller = new Controller(vi.fn(), vi.fn()); @@ -302,7 +302,12 @@ describe('Extension: NetworkMap', () => { type: 'Router', }, { - definition: {description: 'external', model: 'external_converter_device', supports: 'linkquality', vendor: 'external'}, + definition: { + description: 'external/converter', + model: 'external_converter_device', + supports: 'linkquality', + vendor: 'external', + }, friendlyName: '0x0017880104e45511', ieeeAddr: '0x0017880104e45511', lastSeen: 1000, @@ -345,7 +350,7 @@ describe('Extension: NetworkMap', () => { "0x0017880104e45525" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{0x0017880104e45525|0x0017880104e45525 (0x1988)failed: lqi,routingTable|Boef Automatically generated definition (notSupportedModelID)|9 seconds ago}"]; "0x0017880104e45559" [style="rounded, filled", fillcolor="#4ea3e0", fontcolor="#ffffff", label="{cc2530_router|0x0017880104e45559 (0x198c)|Custom devices (DiY) CC2530 router (CC2530.ROUTER)|9 seconds ago}"]; "0x0017880104e45559" -> "0x000b57fffec6a5b2" [penwidth=0.5, weight=0, color="#994444", label="100"] - "0x0017880104e45511" [style="rounded, dashed, filled", fillcolor="#fff8ce", fontcolor="#000000", label="{0x0017880104e45511|0x0017880104e45511 (0x045a)|external external (external_converter_device)|9 seconds ago}"]; + "0x0017880104e45511" [style="rounded, dashed, filled", fillcolor="#fff8ce", fontcolor="#000000", label="{0x0017880104e45511|0x0017880104e45511 (0x045a)|external external/converter (external_converter_device)|9 seconds ago}"]; "0x0017880104e45511" -> "0x00124b00120144ae" [penwidth=1, weight=0, color="#994444", label="92"] }`; @@ -378,7 +383,7 @@ describe('Extension: NetworkMap', () => { --- 0x0017880104e45511 (0x045a) --- - external external (external_converter_device) + external external/converter (external_converter_device) --- 9 seconds ago ] @@ -604,7 +609,12 @@ describe('Extension: NetworkMap', () => { type: 'Router', }, { - definition: {description: 'external', model: 'external_converter_device', supports: 'linkquality', vendor: 'external'}, + definition: { + description: 'external/converter', + model: 'external_converter_device', + supports: 'linkquality', + vendor: 'external', + }, friendlyName: '0x0017880104e45511', ieeeAddr: '0x0017880104e45511', lastSeen: 1000, @@ -756,7 +766,12 @@ describe('Extension: NetworkMap', () => { type: 'Router', }, { - definition: {description: 'external', model: 'external_converter_device', supports: 'linkquality', vendor: 'external'}, + definition: { + description: 'external/converter', + model: 'external_converter_device', + supports: 'linkquality', + vendor: 'external', + }, friendlyName: '0x0017880104e45511', ieeeAddr: '0x0017880104e45511', lastSeen: 1000,