From ba7a85bbb515c793d74e9290ea2dd6d32e5e635f Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Fri, 4 Sep 2020 18:42:24 +0200 Subject: [PATCH] Frontend (#4232) * Initial implementation of backend for frontend. * Frontend fixes (#4205) * Send data to frontend api withoud baseTopic preffix * Send frontend api requests to mqtt * Fix topic name sanitisation * Fix base_topic trimming * Update frontend.js * Add zigbee2mqtt-frontend dependency Co-authored-by: Koen Kanters * Dont' setup separate MQTT connection. * Correctly stop * Add frontend tests. * [WIP] Bindings structure change (#4233) * Change bindings location * Bump frontend version * Republish devices on bindings change * Fix data structure * Fix payload double encoding * Change endpoints structure * Expose config to bridge/info * Fix typo * Updates Co-authored-by: Koen Kanters * Resend states on ws reconnect * Update deps * Bump frontend (#4264) Co-authored-by: John Doe --- lib/controller.js | 6 ++ lib/eventBus.js | 1 + lib/extension/bind.js | 2 + lib/extension/bridge.js | 8 ++ lib/extension/frontend.js | 116 ++++++++++++++++++++++ lib/mqtt.js | 6 +- npm-shrinkwrap.json | 196 +++++++++++++++++++++++++++----------- package.json | 6 +- test/bind.test.js | 20 ++-- test/bridge.test.js | 3 +- test/frontend.test.js | 194 +++++++++++++++++++++++++++++++++++++ 11 files changed, 492 insertions(+), 66 deletions(-) create mode 100644 lib/extension/frontend.js create mode 100644 test/frontend.test.js diff --git a/lib/controller.js b/lib/controller.js index f220d69d..8bee405b 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -13,6 +13,7 @@ const path = require('path'); const assert = require('assert'); // Extensions +const ExtensionFrontend = require('./extension/frontend'); const ExtensionPublish = require('./extension/publish'); const ExtensionReceive = require('./extension/receive'); const ExtensionNetworkMap = require('./extension/networkMap'); @@ -67,6 +68,10 @@ class Controller { this.extensions.push(new ExtensionBridge(...args, this.enableDisableExtension)); } + if (settings.get().experimental.frontend) { + this.extensions.push(new ExtensionFrontend(...args)); + } + if (settings.get().advanced.legacy_api) { this.extensions.push(new ExtensionBridgeLegacy(...args)); } @@ -143,6 +148,7 @@ class Controller { // MQTT this.mqtt.on('message', this.onMQTTMessage.bind(this)); await this.mqtt.connect(); + this.mqtt.publish('bridge/state', 'online', {retain: true, qos: 0}); // Send all cached states. if (settings.get().advanced.cache_state_send_on_startup && settings.get().advanced.cache_state) { diff --git a/lib/eventBus.js b/lib/eventBus.js index c3b05c97..49a4ffa0 100644 --- a/lib/eventBus.js +++ b/lib/eventBus.js @@ -9,6 +9,7 @@ const allowedEvents = [ 'stateChange', // Entity changes its state 'groupMembersChanged', // Members of a group has been changed 'reportingDisabled', // Reporting is disabled for a device + 'deviceBindingsChanged', // Device bindings changed ]; class EventBus extends events.EventEmitter { diff --git a/lib/extension/bind.js b/lib/extension/bind.js index 04bf8e95..7074439e 100644 --- a/lib/extension/bind.js +++ b/lib/extension/bind.js @@ -157,6 +157,8 @@ class Bind extends Extension { if (error) { logger.error(error); + } else { + this.eventBus.emit(`deviceBindingsChanged`); } } } diff --git a/lib/extension/bridge.js b/lib/extension/bridge.js index e4bcb0bb..19433ef6 100644 --- a/lib/extension/bridge.js +++ b/lib/extension/bridge.js @@ -5,6 +5,7 @@ const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters'); const settings = require('../util/settings'); const Transport = require('winston-transport'); const stringify = require('json-stable-stringify'); +const objectAssignDeep = require(`object-assign-deep`); const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`); @@ -38,6 +39,7 @@ class Bridge extends Extension { this.coordinatorVersion = await this.zigbee.getCoordinatorVersion(); this.eventBus.on(`groupMembersChanged`, () => this.publishGroups()); + this.eventBus.on(`deviceBindingsChanged`, () => this.publishDevices()); this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/request/#`); await this.publishInfo(); await this.publishDevices(); @@ -174,6 +176,7 @@ class Bridge extends Extension { } settings.set(['advanced', 'last_seen'], message); + this.publishInfo(); return utils.getResponse(message, {value}, null); } @@ -186,6 +189,7 @@ class Bridge extends Extension { this.enableDisableExtension(value, 'HomeAssistant'); settings.set(['homeassistant'], value); + this.publishInfo(); return utils.getResponse(message, {value}, null); } @@ -197,6 +201,7 @@ class Bridge extends Extension { } settings.set(['advanced', 'elapsed'], value); + this.publishInfo(); return utils.getResponse(message, {value}, null); } @@ -362,6 +367,8 @@ class Bridge extends Extension { } async publishInfo() { + const config = objectAssignDeep.noMutate({}, settings.get()); + delete config.advanced.network_key; const payload = { version: this.zigbee2mqttVersion.version, commit: this.zigbee2mqttVersion.commitHash, @@ -369,6 +376,7 @@ class Bridge extends Extension { network: utils.toSnakeCase(await this.zigbee.getNetworkParameters()), log_level: logger.getLevel(), permit_join: await this.zigbee.getPermitJoin(), + config, }; await this.mqtt.publish('bridge/info', stringify(payload), {retain: true, qos: 0}); diff --git a/lib/extension/frontend.js b/lib/extension/frontend.js new file mode 100644 index 00000000..86f5bf78 --- /dev/null +++ b/lib/extension/frontend.js @@ -0,0 +1,116 @@ +const http = require('http'); +const httpProxy = require('http-proxy'); +const nStatic = require('node-static'); +const Extension = require('./extension'); +const logger = require('../util/logger'); +const frontend = require('zigbee2mqtt-frontend'); +const WebSocket = require('ws'); +const url = require('url'); +const settings = require('../util/settings'); +const utils = require('../util/utils'); +const stringify = require('json-stable-stringify'); + +/** + * This extension servers the frontend + */ +class Frontend extends Extension { + constructor(zigbee, mqtt, state, publishEntityState, eventBus) { + super(zigbee, mqtt, state, publishEntityState, eventBus); + this.onRequest = this.onRequest.bind(this); + this.onUpgrade = this.onUpgrade.bind(this); + this.mqttBaseTopic = settings.get().mqtt.base_topic; + this.onMQTTPublishedMessage = this.onMQTTPublishedMessage.bind(this); + this.mqtt.on('publishedMessage', this.onMQTTPublishedMessage); + this.onWebSocketConnection = this.onWebSocketConnection.bind(this); + this.server = http.createServer(this.onRequest); + this.server.on('upgrade', this.onUpgrade); + this.developmentServer = settings.get().experimental.frontend.development_server; + this.development = !!this.developmentServer; + this.port = settings.get().experimental.frontend.port || 8080; + this.retainedMessages = new Map(); + + if (this.development) { + this.proxy = httpProxy.createProxyServer({ws: true}); + } else { + this.fileServer = new nStatic.Server(frontend.getPath()); + } + + this.wss = new WebSocket.Server({noServer: true}); + this.wss.on('connection', this.onWebSocketConnection); + } + + onZigbeeStarted() { + if (this.development) { + logger.info(`Running frontend in development mode (${this.developmentServer})`); + } + + this.server.listen(this.port); + logger.info(`Started frontend on port ${this.port}`); + } + + async stop() { + for (const client of this.wss.clients) { + client.close(); + } + + return new Promise((resolve) => { + this.server.close(resolve); + }); + } + + onRequest(request, response) { + if (this.development) { + this.proxy.web(request, response, {target: `http://${this.developmentServer}`}); + } else { + this.fileServer.serve(request, response); + } + } + + onUpgrade(request, socket, head) { + const pathname = url.parse(request.url).pathname; + if (pathname === '/api') { + const wss = this.wss; + wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws, request)); + } else if (this.development && pathname === '/sockjs-node') { + this.proxy.ws(request, socket, head, {target: `ws://${this.developmentServer}`}); + } else { + socket.destroy(); + } + } + + onWebSocketConnection(ws) { + ws.on('message', (message) => { + const {topic, payload} = utils.parseJSON(message, message); + this.mqtt.onMessage(`${this.mqttBaseTopic}/${topic}`, stringify(payload)); + }); + + for (const [key, value] of this.retainedMessages) { + ws.send(stringify({topic: key, payload: value})); + } + + for (const device of this.zigbee.getClients()) { + if (this.state.exists(device.ieeeAddr)) { + const resolvedEntity = this.zigbee.resolveEntity(device); + ws.send(stringify({topic: resolvedEntity.name, payload: this.state.get(device.ieeeAddr)})); + } + } + } + + onMQTTPublishedMessage(data) { + let {topic, payload, options} = data; + // Send topic without base_topic + topic = topic.substring(this.mqttBaseTopic.length + 1); + payload = utils.parseJSON(payload, payload); + if (options.retain) { + this.retainedMessages.set(topic, payload); + } + + for (const client of this.wss.clients) { + if (client.readyState === WebSocket.OPEN) { + client.send(stringify({topic, payload})); + } + } + } +} + +module.exports = Frontend; diff --git a/lib/mqtt.js b/lib/mqtt.js index 920c3747..ea6a791c 100644 --- a/lib/mqtt.js +++ b/lib/mqtt.js @@ -71,7 +71,6 @@ class MQTT extends events.EventEmitter { this.client.on('connect', () => { logger.info('Connected to MQTT server'); - this.publish('bridge/state', 'online', {retain: true, qos: 0}); resolve(); }); @@ -116,7 +115,10 @@ class MQTT extends events.EventEmitter { } return new Promise((resolve) => { - this.client.publish(topic, payload, options, () => resolve()); + this.client.publish(topic, payload, options, () => { + this.emit('publishedMessage', {topic, payload, options}); + resolve(); + }); }); } } diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 883a33d6..cfd9c93f 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -14,19 +14,19 @@ } }, "@babel/core": { - "version": "7.11.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.11.4.tgz", - "integrity": "sha512-5deljj5HlqRXN+5oJTY7Zs37iH3z3b++KjiKtIsJy1NrjOOVSEaJHEetLBhyu0aQOSNNZ/0IuEAan9GzRuDXHg==", + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.11.6.tgz", + "integrity": "sha512-Wpcv03AGnmkgm6uS6k8iwhIwTrcP0m17TL1n1sy7qD0qelDu4XNeW0dN0mHfa+Gei211yDaLoEe/VlbXQzM4Bg==", "dev": true, "requires": { "@babel/code-frame": "^7.10.4", - "@babel/generator": "^7.11.4", + "@babel/generator": "^7.11.6", "@babel/helper-module-transforms": "^7.11.0", "@babel/helpers": "^7.10.4", - "@babel/parser": "^7.11.4", + "@babel/parser": "^7.11.5", "@babel/template": "^7.10.4", - "@babel/traverse": "^7.11.0", - "@babel/types": "^7.11.0", + "@babel/traverse": "^7.11.5", + "@babel/types": "^7.11.5", "convert-source-map": "^1.7.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.1", @@ -52,12 +52,12 @@ } }, "@babel/generator": { - "version": "7.11.4", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.11.4.tgz", - "integrity": "sha512-Rn26vueFx0eOoz7iifCN2UHT6rGtnkSGWSoDRIy8jZN3B91PzeSULbswfLoOWuTuAcNwpG/mxy+uCTDnZ9Mp1g==", + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.11.6.tgz", + "integrity": "sha512-DWtQ1PV3r+cLbySoHrwn9RWEgKMBLLma4OBQloPRyDYvc5msJM9kvTLo1YnlJd1P/ZuKbdli3ijr5q3FvAF3uA==", "dev": true, "requires": { - "@babel/types": "^7.11.0", + "@babel/types": "^7.11.5", "jsesc": "^2.5.1", "source-map": "^0.5.0" }, @@ -211,9 +211,9 @@ } }, "@babel/parser": { - "version": "7.11.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.11.4.tgz", - "integrity": "sha512-MggwidiH+E9j5Sh8pbrX5sJvMcsqS5o+7iB42M9/k0CD63MjYbdP4nhSh7uB5wnv2/RVzTZFTxzF/kIa5mrCqA==", + "version": "7.11.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.11.5.tgz", + "integrity": "sha512-X9rD8qqm695vgmeaQ4fvz/o3+Wk4ZzQvSHkDBgpYKxpD4qTAUm88ZKtHkVqIOsYFFbIQ6wQYhC6q7pjqVK0E0Q==", "dev": true }, "@babel/plugin-syntax-async-generators": { @@ -327,17 +327,17 @@ } }, "@babel/traverse": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.11.0.tgz", - "integrity": "sha512-ZB2V+LskoWKNpMq6E5UUCrjtDUh5IOTAyIl0dTjIEoXum/iKWkoIEKIRDnUucO6f+2FzNkE0oD4RLKoPIufDtg==", + "version": "7.11.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.11.5.tgz", + "integrity": "sha512-EjiPXt+r7LiCZXEfRpSJd+jUMnBd4/9OUv7Nx3+0u9+eimMwJmG0Q98lw4/289JCoxSE8OolDMNZaaF/JZ69WQ==", "dev": true, "requires": { "@babel/code-frame": "^7.10.4", - "@babel/generator": "^7.11.0", + "@babel/generator": "^7.11.5", "@babel/helper-function-name": "^7.10.4", "@babel/helper-split-export-declaration": "^7.11.0", - "@babel/parser": "^7.11.0", - "@babel/types": "^7.11.0", + "@babel/parser": "^7.11.5", + "@babel/types": "^7.11.5", "debug": "^4.1.0", "globals": "^11.1.0", "lodash": "^4.17.19" @@ -352,9 +352,9 @@ } }, "@babel/types": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.11.0.tgz", - "integrity": "sha512-O53yME4ZZI0jO1EVGtF1ePGl0LHirG4P1ibcD80XyzZcKhcMFeCXmh4Xb1ifGBIV233Qg12x4rBfQgA+tmOukA==", + "version": "7.11.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.11.5.tgz", + "integrity": "sha512-bvM7Qz6eKnJVFIn+1LPtjlBFPVN5jNDc1XmN15vWe7Q3DPBufWWsLiIvUu7xW87uTG6QoggpIDnUgLQvPheU+Q==", "dev": true, "requires": { "@babel/helper-validator-identifier": "^7.10.4", @@ -388,6 +388,24 @@ "kuler": "^2.0.0" } }, + "@eslint/eslintrc": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.1.3.tgz", + "integrity": "sha512-4YVwPkANLeNtRjMekzux1ci8hIaH5eGKktGqR0d3LWsKNn5B2X/1Z6Trxy7jQXl9EBGE6Yj02O+t09FMeRllaA==", + "dev": true, + "requires": { + "ajv": "^6.12.4", + "debug": "^4.1.1", + "espree": "^7.3.0", + "globals": "^12.1.0", + "ignore": "^4.0.6", + "import-fresh": "^3.2.1", + "js-yaml": "^3.13.1", + "lodash": "^4.17.19", + "minimatch": "^3.0.4", + "strip-json-comments": "^3.1.1" + } + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -706,15 +724,15 @@ } }, "@types/json-schema": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.5.tgz", - "integrity": "sha512-7+2BITlgjgDhH0vvwZU/HZJVyk+2XUlvxXe8dFMedNX/aMkaOq++rMAFXc0tM7ij15QaWlbdQASBR9dihi+bDQ==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz", + "integrity": "sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw==", "dev": true }, "@types/node": { - "version": "14.6.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.6.2.tgz", - "integrity": "sha512-onlIwbaeqvZyniGPfdw/TEhKIh79pz66L1q06WUQqJLnAb6wbjvOtepLYTGHTqzdXgBYIE3ZdmqHDGsRsbBz7A==", + "version": "14.6.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.6.4.tgz", + "integrity": "sha512-Wk7nG1JSaMfMpoMJDKUsWYugliB2Vy55pdjLpmLixeyMi7HizW2I/9QoxsPCkXl3dO+ZOVqPumKaDUv5zJu2uQ==", "dev": true }, "@types/normalize-package-data": { @@ -1883,12 +1901,13 @@ } }, "eslint": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.7.0.tgz", - "integrity": "sha512-1KUxLzos0ZVsyL81PnRN335nDtQ8/vZUD6uMtWbF+5zDtjKcsklIi78XoE0MVL93QvWTu+E5y44VyyCsOMBrIg==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.8.1.tgz", + "integrity": "sha512-/2rX2pfhyUG0y+A123d0ccXtMm7DV7sH1m3lk9nk2DZ2LReq39FXHueR9xZwshE5MdfSf0xunSaMWRqyIA6M1w==", "dev": true, "requires": { "@babel/code-frame": "^7.0.0", + "@eslint/eslintrc": "^0.1.3", "ajv": "^6.10.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", @@ -1898,7 +1917,7 @@ "eslint-scope": "^5.1.0", "eslint-utils": "^2.1.0", "eslint-visitor-keys": "^1.3.0", - "espree": "^7.2.0", + "espree": "^7.3.0", "esquery": "^1.2.0", "esutils": "^2.0.2", "file-entry-cache": "^5.0.1", @@ -2020,12 +2039,20 @@ } }, "esrecurse": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz", - "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "requires": { - "estraverse": "^4.1.0" + "estraverse": "^5.2.0" + }, + "dependencies": { + "estraverse": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", + "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", + "dev": true + } } }, "estraverse": { @@ -2049,6 +2076,11 @@ "es5-ext": "~0.10.14" } }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "exec-sh": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.4.tgz", @@ -2438,6 +2470,11 @@ "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" }, + "follow-redirects": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz", + "integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==" + }, "for-in": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", @@ -2707,6 +2744,16 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "requires": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + } + }, "http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -3641,9 +3688,9 @@ "dev": true }, "json-parse-even-better-errors": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.0.tgz", - "integrity": "sha512-o3aP+RsWDJZayj1SbHNQAI8x0v3T3SKiGoZlNYfbUP1S3omJQ6i9CnqADqkSPaOAxwua4/1YWx5CM7oiChJt2Q==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, "json-schema": { @@ -3830,6 +3877,11 @@ "picomatch": "^2.0.5" } }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, "mime-db": { "version": "1.44.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", @@ -3928,22 +3980,13 @@ } }, "mqtt-packet": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-6.4.0.tgz", - "integrity": "sha512-dNd1RPyBolklOR27hgHhy3TxkDk31ZaDu4ljAgJoHlnVsdACH8guwEZhpk3ZMn6GAdH6ENDLgtE285FHIiXzxA==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-6.5.0.tgz", + "integrity": "sha512-Pzv0auCMip3D2JZId5n8q084ASyi3AvpjP1qJUf9d/gjuk2YkjD5xSUe/KPKgXIqfr5x99bRm5FdAm7ag96RBQ==", "requires": { "bl": "^4.0.2", "debug": "^4.1.1", - "inherits": "^2.0.4", - "process-nextick-args": "^2.0.1", - "safe-buffer": "^5.2.1" - }, - "dependencies": { - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" - } + "process-nextick-args": "^2.0.1" } }, "ms": { @@ -4020,6 +4063,16 @@ "which": "^2.0.2" } }, + "node-static": { + "version": "0.7.11", + "resolved": "https://registry.npmjs.org/node-static/-/node-static-0.7.11.tgz", + "integrity": "sha512-zfWC/gICcqb74D9ndyvxZWaI1jzcoHmf4UTHWQchBNuNMxdBLJMDiUgZ1tjGLEIe/BMhj2DxKD8HOuc2062pDQ==", + "requires": { + "colors": ">=0.6.0", + "mime": "^1.2.9", + "optimist": ">=0.3.4" + } + }, "normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -4154,6 +4207,22 @@ "mimic-fn": "^2.1.0" } }, + "optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", + "requires": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + }, + "dependencies": { + "minimist": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", + "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=" + } + } + }, "optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -4601,6 +4670,11 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "dev": true }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" + }, "resolve": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", @@ -5654,9 +5728,9 @@ } }, "uri-js": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", - "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.0.tgz", + "integrity": "sha512-B0yRTzYdUCCn9n+F4+Gh4yIDtMQcaJsmYBDsTSG8g/OejKBodLQ2IHfN3bM7jUsRXndopT7OIXWdYqc1fjmV6g==", "requires": { "punycode": "^2.1.0" } @@ -5857,6 +5931,11 @@ "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", "dev": true }, + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=" + }, "wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -19913,6 +19992,11 @@ } } } + }, + "zigbee2mqtt-frontend": { + "version": "0.1.44", + "resolved": "https://registry.npmjs.org/zigbee2mqtt-frontend/-/zigbee2mqtt-frontend-0.1.44.tgz", + "integrity": "sha512-C7jU5v+rXQNpWOP7NjwWLjL1uACqk+u+saaHXhT1zlH2E2wGp0KMg+q0yAt2aYKRBK8pyhjSXDVo0szyQekVgQ==" } } } diff --git a/package.json b/package.json index d12494fa..57478147 100644 --- a/package.json +++ b/package.json @@ -37,19 +37,23 @@ "debounce": "^1.2.0", "fast-deep-equal": "^3.1.3", "git-last-commit": "^1.0.0", + "http-proxy": "^1.18.1", "humanize-duration": "^3.23.1", "js-yaml": "^3.14.0", "json-stable-stringify": "^1.0.1", "mkdir-recursive": "^0.4.0", "moment": "^2.27.0", "mqtt": "^4.2.1", + "node-static": "^0.7.11", "object-assign-deep": "^0.4.0", "rimraf": "^3.0.2", "semver": "^7.3.2", "winston": "^3.3.3", "winston-syslog": "^2.4.4", + "ws": "^7.3.1", "zigbee-herdsman": "0.12.130", - "zigbee-herdsman-converters": "12.0.179" + "zigbee-herdsman-converters": "12.0.179", + "zigbee2mqtt-frontend": "^0.1.44" }, "devDependencies": { "eslint": "*", diff --git a/test/bind.test.js b/test/bind.test.js index ddb3b3db..4b051b7c 100644 --- a/test/bind.test.js +++ b/test/bind.test.js @@ -50,11 +50,19 @@ describe('Bind', () => { expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target); expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target); + expect(MQTT.publish).toHaveBeenCalledTimes(5); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/response/device/bind', stringify({"data":{"from":"remote","to":"bulb_color","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}), {retain: false, qos: 0}, expect.any(Function) ); + + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/devices', + expect.any(String), + { retain: true, qos: 0 }, + expect.any(Function) + ); }); it('Should bind only specifief clusters', async () => { @@ -266,7 +274,7 @@ describe('Bind', () => { expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target); expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target); - expect(MQTT.publish).toHaveBeenCalledTimes(3); + expect(MQTT.publish).toHaveBeenCalledTimes(4); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'bulb_color', cluster: 'genScenes'}}); expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log'); @@ -297,7 +305,7 @@ describe('Bind', () => { expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target); expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target); - expect(MQTT.publish).toHaveBeenCalledTimes(3); + expect(MQTT.publish).toHaveBeenCalledTimes(4); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'bulb_color', cluster: 'genScenes'}}); expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log'); @@ -318,7 +326,7 @@ describe('Bind', () => { expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target); expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target); - expect(MQTT.publish).toHaveBeenCalledTimes(3); + expect(MQTT.publish).toHaveBeenCalledTimes(4); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'Coordinator', cluster: 'genScenes'}}); expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log'); @@ -338,7 +346,7 @@ describe('Bind', () => { expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target); expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target); - expect(MQTT.publish).toHaveBeenCalledTimes(3); + expect(MQTT.publish).toHaveBeenCalledTimes(4); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'group_1', cluster: 'genScenes'}}); expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log'); @@ -358,7 +366,7 @@ describe('Bind', () => { expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target); expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target); - expect(MQTT.publish).toHaveBeenCalledTimes(3); + expect(MQTT.publish).toHaveBeenCalledTimes(4); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'group_1', cluster: 'genScenes'}}); expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log'); @@ -412,7 +420,7 @@ describe('Bind', () => { expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", 901); expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", 901); expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", 901); - expect(MQTT.publish).toHaveBeenCalledTimes(3); + expect(MQTT.publish).toHaveBeenCalledTimes(4); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log'); expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'default_bind_group', cluster: 'genScenes'}}); expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/bridge/log'); diff --git a/test/bridge.test.js b/test/bridge.test.js index 78d217e5..7c982589 100644 --- a/test/bridge.test.js +++ b/test/bridge.test.js @@ -38,9 +38,10 @@ describe('Bridge', () => { it('Should publish bridge info on startup', async () => { const version = await require('../lib/util/utils').getZigbee2mqttVersion(); + const directory = settings.get().advanced.log_directory; expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/info', - stringify({"version":version.version,"commit":version.commitHash,"coordinator":{"type":"z-Stack","meta":{"version":1,"revision":20190425}},"network":{"channel":15,"pan_id":5674,"extended_pan_id":[0,11,22]},"log_level":"info","permit_join":false}), + stringify({"commit":version.commitHash,"config":{"advanced":{"adapter_concurrent":null,"availability_blacklist":[],"availability_blocklist":[],"availability_passlist":[],"availability_timeout":0,"availability_whitelist":[],"cache_state":true,"cache_state_persistent":true,"cache_state_send_on_startup":true,"channel":11,"elapsed":false,"ext_pan_id":[221,221,221,221,221,221,221,221],"homeassistant_discovery_topic":"homeassistant","homeassistant_legacy_triggers":true,"homeassistant_status_topic":"hass/status","last_seen":"disable","legacy_api":false,"log_directory":directory,"log_file":"log.txt","log_level":"info","log_output":["console","file"],"log_rotation":true,"log_syslog":{},"pan_id":6754,"report":false,"soft_reset_timeout":0,"timestamp_format":"YYYY-MM-DD HH:mm:ss"},"ban":[],"blocklist":[],"device_options":{},"devices":{"0x000b57fffec6a5b2":{"friendly_name":"bulb","retain":true},"0x000b57fffec6a5b3":{"friendly_name":"bulb_color","retain":false},"0x000b57fffec6a5b4":{"friendly_name":"bulb_color_2","retain":false},"0x000b57fffec6a5b7":{"friendly_name":"bulb_2","retain":false},"0x0017880104a44559":{"friendly_name":"J1_cover"},"0x0017880104e43559":{"friendly_name":"U202DST600ZB"},"0x0017880104e44559":{"friendly_name":"3157100_thermostat"},"0x0017880104e45517":{"friendly_name":"remote","retain":true},"0x0017880104e45518":{"friendly_name":"0x0017880104e45518"},"0x0017880104e45520":{"friendly_name":"button","retain":false},"0x0017880104e45521":{"friendly_name":"button_double_key","retain":false},"0x0017880104e45522":{"friendly_name":"weather_sensor","qos":1,"retain":false},"0x0017880104e45523":{"friendly_name":"occupancy_sensor","retain":false},"0x0017880104e45524":{"friendly_name":"power_plug","retain":false},"0x0017880104e45526":{"friendly_name":"GL-S-007ZS"},"0x0017880104e45529":{"friendly_name":"unsupported2","retain":false},"0x0017880104e45530":{"friendly_name":"button_double_key_interviewing","retain":false},"0x0017880104e45540":{"friendly_name":"ikea_onoff"},"0x0017880104e45541":{"friendly_name":"wall_switch","retain":false},"0x0017880104e45542":{"friendly_name":"wall_switch_double","retain":false},"0x0017880104e45543":{"friendly_name":"led_controller_1","retain":false},"0x0017880104e45544":{"friendly_name":"led_controller_2","retain":false},"0x0017880104e45545":{"friendly_name":"dimmer_wall_switch","retain":false},"0x0017880104e45547":{"friendly_name":"curtain","retain":false},"0x0017880104e45548":{"friendly_name":"fan","retain":false},"0x0017880104e45549":{"friendly_name":"siren","retain":false},"0x0017880104e45550":{"friendly_name":"thermostat","retain":false},"0x0017880104e45551":{"friendly_name":"smart vent","retain":false},"0x0017880104e45552":{"friendly_name":"j1","retain":false},"0x0017880104e45553":{"friendly_name":"bulb_enddevice","retain":false},"0x0017880104e45559":{"friendly_name":"cc2530_router","retain":false},"0x0017880104e45560":{"friendly_name":"livolo","retain":false},"0x0017882104a44559":{"friendly_name":"TS0601_thermostat"},"0x90fd9ffffe4b64aa":{"friendly_name":"SP600_OLD"},"0x90fd9ffffe4b64ab":{"friendly_name":"SP600_NEW"},"0x90fd9ffffe4b64ac":{"friendly_name":"MKS-CM-W5"},"0x90fd9ffffe4b64ae":{"friendly_name":"tradfri_remote","retain":false},"0x90fd9ffffe4b64af":{"friendly_name":"roller_shutter"},"0x90fd9ffffe4b64ax":{"friendly_name":"ZNLDP12LM"}},"experimental":{"new_api":true,"output":"json"},"external_converters":[],"groups":{"1":{"friendly_name":"group_1","retain":false},"11":{"devices":["bulb_2"],"friendly_name":"group_with_tradfri","retain":false},"12":{"devices":["TS0601_thermostat"],"friendly_name":"thermostat_group","retain":false},"15071":{"devices":["bulb_color_2","bulb_2"],"friendly_name":"group_tradfri_remote","retain":false},"2":{"friendly_name":"group_2","retain":false}},"homeassistant":false,"map_options":{"graphviz":{"colors":{"fill":{"coordinator":"#e04e5d","enddevice":"#fff8ce","router":"#4ea3e0"},"font":{"coordinator":"#ffffff","enddevice":"#000000","router":"#ffffff"},"line":{"active":"#009900","inactive":"#994444"}}}},"mqtt":{"base_topic":"zigbee2mqtt","include_device_information":false,"server":"mqtt://localhost"},"passlist":[],"permit_join":true,"serial":{"disable_led":false,"port":"/dev/dummy"},"whitelist":[]},"coordinator":{"meta":{"revision":20190425,"version":1},"type":"z-Stack"},"log_level":"info","network":{"channel":15,"extended_pan_id":[0,11,22],"pan_id":5674},"permit_join":false,"version":version.version}), { retain: true, qos: 0 }, expect.any(Function) ); diff --git a/test/frontend.test.js b/test/frontend.test.js new file mode 100644 index 00000000..a95349fd --- /dev/null +++ b/test/frontend.test.js @@ -0,0 +1,194 @@ +const data = require('./stub/data'); +require('./stub/logger'); +require('./stub/zigbeeHerdsman'); +const MQTT = require('./stub/mqtt'); +const settings = require('../lib/util/settings'); +const Controller = require('../lib/controller'); +const stringify = require('json-stable-stringify'); +const flushPromises = () => new Promise(setImmediate); +jest.spyOn(process, 'exit').mockImplementation(() => {}); + +const mockHTTP = { + implementation: { + listen: jest.fn(), + on: (event, handler) => {mockHTTP.events[event] = handler}, + close: jest.fn().mockImplementation((cb) => cb()), + }, + variables: {}, + events: {}, +}; + +const mockHTTPProxy = { + implementation: { + web: jest.fn(), + ws: jest.fn(), + }, + variables: {}, + events: {}, +}; + +const mockWS = { + implementation: { + clients: [], + on: (event, handler) => {mockWS.events[event] = handler}, + handleUpgrade: jest.fn(), + emit: jest.fn(), + }, + variables: {}, + events: {}, +}; + +const mockNodeStatic = { + implementation: { + serve: jest.fn(), + }, + variables: {}, + events: {}, +}; + +jest.mock('http', () => ({ + createServer: jest.fn().mockImplementation((onRequest) => { + mockHTTP.variables.onRequest = onRequest; + return mockHTTP.implementation; + }), +})); + +jest.mock('http-proxy', () => ({ + createProxyServer: jest.fn().mockImplementation((initParameter) => { + mockHTTPProxy.variables.initParameter = initParameter; + return mockHTTPProxy.implementation; + }), +})); + +jest.mock('node-static', () => ({ + Server: jest.fn().mockImplementation((path) => { + mockNodeStatic.variables.path = path; + return mockNodeStatic.implementation; + }), +})); + +jest.mock('zigbee2mqtt-frontend', () => ({ + getPath: () => 'my/dummy/path', +})); + +jest.mock('ws', () => ({ + OPEN: 'open', + Server: jest.fn().mockImplementation(() => { + return mockWS.implementation; + }), +})); + +describe('Frontend', () => { + let controller; + + beforeEach(async () => { + mockWS.implementation.clients = []; + data.writeDefaultConfiguration(); + data.writeDefaultState(); + settings._reRead(); + settings.set(['experimental'], {new_api: true, frontend: {port: 8081}}); + }); + + it('Start/stop', async () => { + controller = new Controller(); + await controller.start(); + expect(mockNodeStatic.variables.path).toBe("my/dummy/path"); + expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081); + + const mockWSClient = { + implementation: { + close: jest.fn(), + }, + events: {}, + }; + mockWS.implementation.clients.push(mockWSClient.implementation); + await controller.stop(); + expect(mockWSClient.implementation.close).toHaveBeenCalledTimes(1); + expect(mockHTTP.implementation.close).toHaveBeenCalledTimes(1); + }); + + it('Websocket interaction', async () => { + controller = new Controller(); + await controller.start(); + + // Connect + const mockWSClient = { + implementation: { + on: (event, handler) => {mockWSClient.events[event] = handler}, + send: jest.fn(), + readyState: 'open', + }, + events: {}, + }; + mockWS.implementation.clients.push(mockWSClient.implementation); + await mockWS.events.connection(mockWSClient.implementation); + expect(mockWSClient.implementation.send).toHaveBeenCalledTimes(9); + expect(JSON.parse(mockWSClient.implementation.send.mock.calls[0])).toStrictEqual({topic: 'bridge/state', payload: 'online'}); + expect(JSON.parse(mockWSClient.implementation.send.mock.calls[8])).toStrictEqual({topic:"remote", payload:{brightness:255}}); + + // Message + MQTT.publish.mockClear(); + mockWSClient.implementation.send.mockClear(); + mockWSClient.events.message(stringify({topic: 'bulb_color/set', payload: {state: 'ON'}})) + await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledTimes(1); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bulb_color', + stringify({state: 'ON'}), + { retain: false, qos: 0 }, + expect.any(Function) + ); + + // Received message on socket + expect(mockWSClient.implementation.send).toHaveBeenCalledTimes(1); + expect(mockWSClient.implementation.send).toHaveBeenCalledWith(stringify({topic: 'bulb_color', payload: {state: 'ON'}})); + + // Shouldnt set when not ready + mockWSClient.implementation.send.mockClear(); + mockWSClient.implementation.readyState = 'close'; + mockWSClient.events.message(stringify({topic: 'bulb_color/set', payload: {state: 'ON'}})) + expect(mockWSClient.implementation.send).toHaveBeenCalledTimes(0); + }); + + it('onReques/onUpgrade', async () => { + controller = new Controller(); + await controller.start(); + + const mockSocket = {destroy: jest.fn()}; + mockWS.implementation.handleUpgrade.mockClear(); + mockHTTP.events.upgrade({url: 'http://localhost:8080/api'}, mockSocket, 3); + expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledTimes(1); + expect(mockSocket.destroy).toHaveBeenCalledTimes(0); + expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledWith({"url": "http://localhost:8080/api"}, mockSocket, 3, expect.any(Function)); + mockWS.implementation.handleUpgrade.mock.calls[0][3](99); + expect(mockWS.implementation.emit).toHaveBeenCalledWith('connection', 99, {"url": "http://localhost:8080/api"}); + + mockWS.implementation.handleUpgrade.mockClear(); + mockHTTP.events.upgrade({url: 'http://localhost:8080/unkown'}, mockSocket, 3); + expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledTimes(0); + expect(mockSocket.destroy).toHaveBeenCalledTimes(1); + + mockHTTP.variables.onRequest(1, 2); + expect(mockNodeStatic.implementation.serve).toHaveBeenCalledTimes(1); + expect(mockNodeStatic.implementation.serve).toHaveBeenCalledWith(1, 2); + }); + + it('Development server', async () => { + settings.set(['experimental', 'frontend'], {development_server: 'localhost:3001'}); + controller = new Controller(); + await controller.start(); + expect(mockHTTPProxy.variables.initParameter).toStrictEqual({ws: true}); + expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8080); + + mockHTTP.variables.onRequest(1, 2); + expect(mockHTTPProxy.implementation.web).toHaveBeenCalledTimes(1); + expect(mockHTTPProxy.implementation.web).toHaveBeenCalledWith(1, 2, {"target": "http://localhost:3001"}); + + const mockSocket = {destroy: jest.fn()}; + mockHTTPProxy.implementation.ws.mockClear(); + mockHTTP.events.upgrade({url: 'http://localhost:8080/sockjs-node'}, mockSocket, 3); + expect(mockHTTPProxy.implementation.ws).toHaveBeenCalledTimes(1); + expect(mockSocket.destroy).toHaveBeenCalledTimes(0); + expect(mockHTTPProxy.implementation.ws).toHaveBeenCalledWith({"url": "http://localhost:8080/sockjs-node"}, mockSocket, 3, {"target": "ws://localhost:3001"}); + }); +});