Release 1.3.0

This commit is contained in:
2021-08-26 12:33:15 +02:00
commit 9bb72f0207
1148 changed files with 92133 additions and 0 deletions

View File

@@ -0,0 +1 @@
lib/**/*.js

View File

@@ -0,0 +1,27 @@
{
"extends": [
"@bthlabs",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
"parser": "babel-eslint",
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"jsx": true,
"modules": true
}
},
"globals": {
"PRODUCTION": "readonly"
},
"settings": {
"react": {
"version": "detect"
}
},
"rules": {
"react/prop-types": "off",
"no-console": ["error", {"allow": ["error"]}]
}
}

66
packages/homehub_core/.gitignore vendored Executable file
View File

@@ -0,0 +1,66 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.envrc
# next.js build output
.next
# homehub_frontend
/lib/
test-results.xml

View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,19 @@
PHONY: clean dev lint test build publish
clean:
rm -rf lib/
dev:
yarn dev
lint:
yarn lint
test:
yarn test --single-run
build: clean
yarn build
publish:
npm publish

View File

@@ -0,0 +1,42 @@
BTHLabs HomeHub - Core
Copyright 2021-present BTHLabs <contact@bthlabs.pl> (https://bthlabs.pl/)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
---
BTHLabs HomeHub - Core includes the following third party software
AWS SDK JS Crypto Helpers
opyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.
Licensed under terms of the Apache License, Version 2.0
Browser-compatible JavaScript MD5
Version 2.2 Copyright (C) Paul Johnston 1999 - 2009
Licensed under terms of the BSD License
classnames
Copyright (c) 2017 Jed Watson
Licensed under terms of the MIT License
luxon
Copyright 2019 JS Foundation and other contributors
Licensed under terms of the MIT License
lodash
Copyright JS Foundation and other contributors
Licensed under terms of the MIT License
prop-types
Copyright (c) 2013-present, Facebook, Inc.
Licensed under terms of the MIT License

View File

@@ -0,0 +1,3 @@
# @bthlabs/homehub-core
BTHLabs HomeHub - Core

View File

@@ -0,0 +1,22 @@
module.exports = {
'presets': [
[
'@babel/preset-env', {
'targets': {
'chrome': 81,
'edge': 18,
'firefox': 75,
'ie': 11,
'opera': 68,
'safari': 13,
},
'modules': 'commonjs',
},
],
'@babel/preset-react',
],
'plugins': [
'@babel/plugin-proposal-class-properties',
'@babel/plugin-proposal-export-default-from',
],
};

View File

@@ -0,0 +1,70 @@
var path = require('path');
const CONTEXT = path.resolve(__dirname);
const SCSS_DIR = path.resolve(CONTEXT, 'scss');
const PROJECT_DIR = path.resolve(CONTEXT, 'src');
const TESTS_DIR = path.resolve(CONTEXT, 'tests');
const ENTRY_POINT = path.resolve(TESTS_DIR, '__entry__.js');
module.exports = function (config) {
config.set({
basePath: '.',
browsers: ['jsdom'],
frameworks: ['jasmine'],
files: [ENTRY_POINT],
junitReporter: {
outputDir: '',
outputFile: 'test-results.xml',
suite: 'homehub_frontend',
useBrowserName: false,
},
mochaReporter: {
ignoreSkipped: true,
},
preprocessors: {
[ENTRY_POINT]: ['webpack', 'sourcemap'],
},
reporters: ['mocha'],
singleRun: false,
webpack: {
mode: 'development',
devtool: 'inline-source-map',
module: {
rules: [
{
test: /\.js?/,
include: PROJECT_DIR,
use: ['babel-loader'],
},
{
test: /\.js$/,
include: TESTS_DIR,
use: ['babel-loader'],
},
{
test: /\.scss$/,
use: ['null-loader'],
},
{
test: /\.(png|svg)$/,
use: ['null-loader'],
},
],
},
resolve: {
alias: {
'scss': SCSS_DIR,
'src': PROJECT_DIR,
'tests': TESTS_DIR,
},
},
externals: {
'cheerio': 'window',
'react/addons': 'react',
'react/lib/ExecutionEnvironment': 'react',
'react/lib/ReactContext': 'react',
},
},
});
};

View File

@@ -0,0 +1,72 @@
{
"name": "@bthlabs/homehub-core",
"version": "1.3.0",
"description": "BTHLabs HomeHub - Core",
"main": "lib/index.js",
"author": "BTHLabs <contact@bthlabs.pl> (https://bthlabs.pl/)",
"license": "Apache-2.0",
"scripts": {
"build": "NODE_ENV=production npx webpack-cli",
"dev": "npx webpack-cli",
"start": "npx webpack-cli --watch",
"lint": "npx eslint src/ tests/ *.js",
"test": "npx karma start",
"test:dist": "npx karma start --single-run --reporters junit"
},
"files": [
"lib/"
],
"publishConfig": {
"registry": "https://nexus.bthlabs.pl/repository/npm-hosted/"
},
"peerDependencies": {
"prop-types": ">=15.7.2",
"react": ">=16.11.0",
"react-dom": ">=16.11.0"
},
"devDependencies": {
"@aws-crypto/sha256-js": "1.0.0",
"@babel/core": "7.7.2",
"@babel/plugin-proposal-class-properties": "7.8.3",
"@babel/plugin-proposal-export-default-from": "7.5.2",
"@babel/preset-env": "7.7.1",
"@babel/preset-react": "7.7.0",
"@bthlabs/eslint-config": "1.0.3",
"babel-eslint": "10.0.3",
"babel-loader": "8.0.6",
"classnames": "2.2.6",
"clean-webpack-plugin": "3.0.0",
"css-loader": "3.5.3",
"enzyme": "3.10.0",
"enzyme-adapter-react-16": "1.15.1",
"eslint": "6.6.0",
"eslint-plugin-react": "7.20.4",
"eslint-plugin-react-hooks": "4.0.8",
"jasmine-core": "3.5.0",
"jsdom": "16.4.0",
"karma": "4.4.1",
"karma-jasmine": "2.0.1",
"karma-jsdom-launcher": "9.0.0",
"karma-junit-reporter": "2.0.1",
"karma-mocha-reporter": "2.2.5",
"karma-sourcemap-loader": "0.3.7",
"karma-webpack": "4.0.2",
"lodash": "4.17.15",
"luxon": "1.24.1",
"node-sass": "4.14.1",
"null-loader": "4.0.0",
"polyfill-crypto.getrandomvalues": "1.0.0",
"prop-types": "15.7.2",
"react": "16.11.0",
"react-dom": "16.11.0",
"react-svg-loader": "3.0.3",
"regenerator-runtime": "0.13.5",
"sass-loader": "8.0.2",
"shallow-with-context": "0.4.1",
"style-loader": "1.2.1",
"uuid": "8.2.0",
"webpack": "4.41.2",
"webpack-cli": "3.3.11",
"whatwg-fetch": "3.6.2"
}
}

View File

@@ -0,0 +1,7 @@
import * as State from './state';
import * as Services from './services';
export default {
State: State,
Services: Services,
};

View File

@@ -0,0 +1,20 @@
import * as RPC from 'src/lib/rpc';
export const start = async (kind, instance, characteristics) => {
const result = await RPC.callMethod(
'services.start', [kind, instance, characteristics]
);
return result;
};
export const stop = async (kind, instance) => {
const result = await RPC.callMethod('services.stop', [kind, instance]);
return result;
};
export const use = async (kind, instance, capability, params) => {
const result = await RPC.callMethod(
'services.use', [kind, instance, capability, params]
);
return result;
};

View File

@@ -0,0 +1,11 @@
import {callMethod} from 'src/lib/rpc';
export const get = async () => {
const result = await callMethod('state.get_frontend');
return result;
};
export const save = async (state) => {
const result = await callMethod('state.save_frontend', [state]);
return result;
};

View File

@@ -0,0 +1,136 @@
import isNull from 'lodash/isNull';
import isUndefined from 'lodash/isUndefined';
import React from 'react';
import {DashboardsContext} from '../context';
import {ServiceState, lookupService} from '../lib';
export class ServiceContainer extends React.Component {
constructor (props) {
super(props);
this.serviceUnsubscribe = null;
this.state = {
serviceState: null,
showSettingsModal: false,
nextCharacteristics: null,
};
}
service = () => {
return lookupService(
this.context.dashboards, this.props.kind, this.props.instance
);
}
setServiceState = (payload, {reset}) => {
let nextServiceState = null;
if (reset || isNull(this.state.serviceState)) {
nextServiceState = new ServiceState(payload);
} else {
nextServiceState = this.state.serviceState.update(payload);
}
this.setState({serviceState: nextServiceState});
}
setNextCharacteristics = (nextCharacteristics) => {
this.setState({nextCharacteristics: nextCharacteristics});
}
setNextCharacteristicsFromService = (fromService) => {
let nextCharacteristics = null;
if (fromService && this.service().widgetComponent && !fromService.isDummy()) {
nextCharacteristics = {...fromService.characteristics};
}
this.setState({nextCharacteristics: nextCharacteristics});
}
showHideSettingsModal = (show) => {
if (show) {
this.setNextCharacteristicsFromService(this.service());
} else {
this.setNextCharacteristicsFromService(null);
}
this.setState({showSettingsModal: show});
}
onSettingsButtonClick = () => {
this.showHideSettingsModal(true);
}
onSettingsModalClose = () => {
this.showHideSettingsModal(false);
}
onSettingsModalSaveButtonClick = () => {
this.context.saveServiceCharacteristics(
this.props.kind, this.props.instance, this.state.nextCharacteristics
);
this.onSettingsModalClose();
}
onSettingsModalNukeButtonClick = () => {
this.context.nukeService(this.props.kind, this.props.instance);
this.onSettingsModalClose();
}
onAppearancePopupColorChange = (color) => {
const currentCharacteristics = this.service().characteristics;
this.context.saveServiceCharacteristics(
this.props.kind, this.props.instance, {
...currentCharacteristics,
appearance: {
...(currentCharacteristics.appearance || {}),
color: color,
},
}
);
}
onServiceRestartButtonClick = () => {
this.service().restart();
}
unsubscribeIfNeeded = () => {
if (!isNull(this.serviceUnsubscribe)) {
this.serviceUnsubscribe();
}
}
componentDidMount () {
const service = this.service();
if (service && service.widgetComponent && !service.isDummy()) {
this.setServiceState(service.initialState(), true);
service.start();
this.unsubscribeIfNeeded();
this.serviceUnsubscribe = service.subscribe(this.setServiceState);
}
}
componentWillUnmount () {
this.unsubscribeIfNeeded();
}
render () {
const WidgetComponent = this.service().widgetComponent;
const hasSettingsView = Boolean(
!this.service().isDummy()
&& WidgetComponent
&& !isUndefined(WidgetComponent.settingsView)
);
const settingsViewProps = {
kind: this.props.kind,
instance: this.props.instance,
nextCharacteristics: this.state.nextCharacteristics,
setNextCharacteristics: this.setNextCharacteristics,
};
return this.props.children({
hasSettingsView: hasSettingsView,
service: this.service(),
serviceState: this.state.serviceState,
setServiceState: this.setServiceState,
settingsViewProps: settingsViewProps,
showSettingsModal: this.state.showSettingsModal,
onAppearancePopupColorChange: this.onAppearancePopupColorChange,
onServiceRestartButtonClick: this.onServiceRestartButtonClick,
onSettingsButtonClick: this.onSettingsButtonClick,
onSettingsModalClose: this.onSettingsModalClose,
onSettingsModalNukeButtonClick: this.onSettingsModalNukeButtonClick,
onSettingsModalSaveButtonClick: this.onSettingsModalSaveButtonClick,
});
}
}
ServiceContainer.contextType = DashboardsContext;

View File

@@ -0,0 +1 @@
export {ServiceContainer} from './ServiceContainer';

View File

@@ -0,0 +1,24 @@
import noop from 'lodash/noop';
import React from 'react';
export const DEFAULT_DASHBOARDS_CONTEXT = {
currentDashboardId: null,
dashboards: [],
nukeService: noop,
saveServiceCharacteristics: noop,
saveServiceLayout: noop,
addService: noop,
isLoading: false,
lastSaveTimestamp: null,
lastSaveError: null,
isSaving: false,
isWebSocketConnected: false,
setCurrentDashboardId: noop,
dashboardsHash: null,
addDashboard: noop,
};
export const DashboardsContext = React.createContext(
DEFAULT_DASHBOARDS_CONTEXT
);
DashboardsContext.displayName = 'DashboardsContext';

View File

@@ -0,0 +1 @@
export * from './DashboardsContext';

View File

@@ -0,0 +1,2 @@
export * from './services';
export * from './widgets';

View File

@@ -0,0 +1,5 @@
export const kServiceDummy = 'kServiceDummy';
export const kServiceTime = 'kServiceTime';
export const kServiceUptime = 'kServiceUptime';
export const kServiceWeather = 'kServiceWeather';
export const kServiceOfflineWeather = 'kServiceOfflineWeather';

View File

@@ -0,0 +1,3 @@
export const kWidgetTime = 'kWidgetTime';
export const kWidgetUptime = 'kWidgetUptime';
export const kWidgetWeather = 'kWidgetWeather';

View File

@@ -0,0 +1,133 @@
import {useContext, useDebugValue} from 'react';
import {DashboardsContext} from 'src/context';
import {lookupService} from 'src/lib';
export const useServices = () => {
const context = useContext(DashboardsContext);
let result = [];
if (context.currentDashboardId) {
const currentDashboard = context.dashboards.find((dashboard) => {
return dashboard.id === context.currentDashboardId;
});
result = currentDashboard.services;
}
useDebugValue(result);
return result;
};
export const useService = (kind, instance) => {
const context = useContext(DashboardsContext);
const result = lookupService(context.dashboards, kind, instance);
useDebugValue(result);
return result;
};
export const useSaveServiceCharacteristics = () => {
const context = useContext(DashboardsContext);
const result = context.saveServiceCharacteristics;
useDebugValue(result);
return result;
};
export const useSaveServiceLayout = () => {
const context = useContext(DashboardsContext);
const result = context.saveServiceLayout;
useDebugValue(result);
return result;
};
export const useNukeService = () => {
const context = useContext(DashboardsContext);
const result = context.nukeService;
useDebugValue(result);
return result;
};
export const useAddService = () => {
const context = useContext(DashboardsContext);
const result = context.addService;
useDebugValue(result);
return result;
};
export const useServicesSavingState = () => {
const context = useContext(DashboardsContext);
const result = {
lastSaveTimestamp: context.lastSaveTimestamp,
lastSaveError: context.lastSaveError,
isSaving: context.isSaving,
};
useDebugValue(result);
return result;
};
export const useWebSocketState = () => {
const context = useContext(DashboardsContext);
const result = {
isWebSocketConnected: context.isWebSocketConnected,
};
useDebugValue(result);
return result;
};
export const useDashboards = () => {
const context = useContext(DashboardsContext);
const result = context.dashboards || [];
useDebugValue(result);
return result;
};
export const useCurrentDashboardId = () => {
const context = useContext(DashboardsContext);
const result = context.currentDashboardId;
useDebugValue(result);
return result;
};
export const useSetCurrentDashboardId = () => {
const context = useContext(DashboardsContext);
const result = context.setCurrentDashboardId;
useDebugValue(result);
return result;
};
export const useDashboardsHash = () => {
const context = useContext(DashboardsContext);
const result = context.dashboardsHash;
useDebugValue(result);
return result;
};
export const useAddDashboard = () => {
const context = useContext(DashboardsContext);
const result = context.addDashboard;
useDebugValue(result);
return result;
};

View File

@@ -0,0 +1,13 @@
/*!
* BTHLabs HomeHub - Core (https://bthlabs.pl/)
* Copyright 2021-present BTHLabs
* Apache License Version 2.0
*/
export {default as API} from 'src/api';
export * from 'src/containers';
export * from 'src/context';
export * from 'src/defs';
export * from 'src/hooks';
export * from 'src/lib';
export * from 'src/providers';

View File

@@ -0,0 +1,70 @@
export class HomeHubBaseClass {
constructor () {
this.__mixins__ = [];
}
}
export const SubscribableMixin = (Base) => {
return class extends Base {
constructor (...args) {
super(...args);
this.__mixins__.push('SubscribableMixin');
this.subscribers = [];
}
notify (payload, userInfo) {
userInfo = userInfo || {};
this.subscribers.forEach((subscriber) => {
if (subscriber !== null) {
subscriber(payload, userInfo);
}
});
}
unsubscriberFactory (subscriberIndex) {
return () => {
this.subscribers.splice(subscriberIndex, 1, null);
};
}
subscribe (callback) {
const subscriberIndex = this.subscribers.push(callback) - 1;
return this.unsubscriberFactory(subscriberIndex);
}
};
};
export const EventSourceMixin = (Base, knownEvents = []) => {
return class extends Base {
constructor (...args) {
super(...args);
this.__mixins__.push('EventSourceMixin');
this.eventListeners = {};
knownEvents.forEach((event) => {
this.eventListeners[event] = [];
});
}
addEventListener (event, listener) {
if (Object.prototype.hasOwnProperty.call(this.eventListeners, event)) {
this.eventListeners[event].push(listener);
}
}
removeEventListener (event, listener) {
if (Object.prototype.hasOwnProperty.call(this.eventListeners, event)) {
const listenerIndex = this.eventListeners[event].indexOf(listener);
if (listenerIndex !== -1) {
this.eventListeners[event].splice(listenerIndex, 1, null);
}
}
}
fireEvent (event) {
if (Object.prototype.hasOwnProperty.call(this.eventListeners, event)) {
this.eventListeners[event].forEach((listener) => {
if (listener !== null) {
listener(this);
}
});
}
}
};
};

View File

@@ -0,0 +1,9 @@
import * as uuid from 'uuid';
export const DashboardFactory = (name = 'Default') => {
return {
id: uuid.v4(),
name: name,
services: [],
};
};

View File

@@ -0,0 +1,12 @@
import {Sha256} from '@aws-crypto/sha256-js';
export const sha256 = async (message) => {
const hash = new Sha256(message);
const digest = await hash.digest();
let result = '';
digest.forEach((byte) => {
result += byte.toString(16).padStart(2, '0');
});
return result;
};

View File

@@ -0,0 +1,12 @@
import * as localStorage from './localStorage';
export * from './base';
export * from './dashboards';
export * from './hashlib';
export * from './services';
export * from './websocket';
export const LocalStorage = {
getItem: localStorage.getItem,
setItem: localStorage.setItem,
};

View File

@@ -0,0 +1,22 @@
export const getItem = (key, def = null) => {
let item = null;
try {
item = window.localStorage.getItem(key);
} catch (err) {
console.error('Unhandled exception when setting local storage item!', err);
}
if (item === null) {
return def;
}
return JSON.parse(item);
};
export const setItem = (key, item) => {
try {
window.localStorage.setItem(key, JSON.stringify(item));
} catch (err) {
console.error('Unhandled exception when setting local storage item!', err);
}
};

View File

@@ -0,0 +1,42 @@
import {v4 as uuidv4} from 'uuid';
export const callMethod = async (method, params, notification = false) => {
const payload = {
jsonrpc: '2.0',
method: method,
};
if (params) {
payload.params = params;
}
if (!notification) {
payload.id = uuidv4();
}
const response = await window.fetch('/backend/rpc', {
method: 'POST',
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
},
});
const result = {};
try {
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data.error) {
throw new Error(`RPC Error: ${data.error.message} ${data.error.data}`);
}
result.data = data.result;
} catch (error) {
result.error = error;
}
return result;
};

View File

@@ -0,0 +1,160 @@
import clone from 'lodash/clone';
import isNull from 'lodash/isNull';
import {kServiceDummy} from 'src/defs';
import {HomeHubBaseClass, SubscribableMixin} from 'src/lib/base';
export class ServiceState extends HomeHubBaseClass {
static DEFAULT_PAYLOAD = {
data: null,
error: null,
};
constructor (payload) {
super(payload);
this.payload = {
...ServiceState.DEFAULT_PAYLOAD,
...(payload || {}),
};
}
isLoading () {
return (isNull(this.payload.data) && isNull(this.payload.error));
}
hasData () {
return (
!this.isLoading() && !isNull(this.payload.data)
);
}
hasError () {
return (
!this.isLoading() && !isNull(this.payload.error)
);
}
hasFatalError () {
return (!this.hasData() && this.hasError());
}
update (nextPayload) {
return new ServiceState({
...this.payload,
...nextPayload,
});
}
data () {
return this.payload.data;
}
error () {
return this.payload.error;
}
}
export class BaseService extends SubscribableMixin(HomeHubBaseClass) {
static availableOffline = false;
static availableOnline = true;
static kind = null;
static widget = null;
static emptyCharacteristics () {
return {};
}
constructor (spec) {
super(spec);
this.kind = this.constructor.kind;
this.instance = spec.instance;
this.characteristics = spec.characteristics;
this.widget = this.constructor.widget;
this.widgetComponent = spec.widgetComponent || null;
this.layout = spec.layout;
}
async start () {
throw new Error('NOT IMPLEMENTED');
}
async stop () {
throw new Error('NOT IMPLEMENTED');
}
async restart () {
this.notify(this.initialState(), {reset: true});
await this.stop();
await this.start();
}
onMessage (data) {
const payload = {
...data,
};
if (data.error) {
payload.error = new Error(data.error);
}
this.notify(payload);
}
isDummy () {
return false;
}
initialState () {
return null;
}
setCharacteristics (newCharacteristics) {
this.characteristics = clone(newCharacteristics);
}
setLayout (newLayout) {
this.layout = clone(newLayout);
}
toJSON () {
return {
kind: this.kind,
instance: this.instance,
characteristics: this.characteristics,
layout: this.layout,
};
}
}
export class DummyService extends BaseService {
kind = kServiceDummy;
constructor (spec) {
super({
...spec,
instance: null,
widgetComponent: null,
});
}
isDummy = () => {
return true;
}
}
export const lookupService = (dashboards, kind, instance) => {
let result = null;
for (const dashboard of dashboards) {
for (const service of dashboard.services) {
if (service.kind === kind && service.instance === instance) {
result = service;
break;
}
}
if (result !== null) {
break;
}
}
if (result === null) {
return new DummyService();
}
return result;
};
export const lookupServices = (dashboards, kind) => {
let result = [];
for (const dashboard of dashboards) {
for (const service of dashboard.services) {
if (service.kind === kind) {
result.push(service);
}
}
}
return result;
};

View File

@@ -0,0 +1,85 @@
import {
EventSourceMixin, HomeHubBaseClass, SubscribableMixin,
} from 'src/lib/base';
export const kWebSocketMsgTypeServiceNotification = 'SERVICE_NOTIFICATION';
export const kWebSocketMsgTypeStateNotification = 'STATE_NOTIFICATION';
const Base = SubscribableMixin(
EventSourceMixin(HomeHubBaseClass, ['start', 'stop'])
);
export class HomeHubWebSocket extends Base {
constructor (debug, settings) {
super(debug, settings);
this.debug = debug;
this.settings = settings;
this.socket = null;
this.reconnectTimeout = null;
this.reconnectCounter = 0;
}
logDebug (...args) {
if (this.debug) {
// eslint-disable-next-line no-console
console.log(...args);
}
}
stopReconnect () {
if (this.reconnectTimeout) {
window.clearTimeout(this.reconnectTimeout);
}
this.reconnectTimeout = null;
this.reconnectCounter = 0;
}
startReconnect () {
if (this.reconnectCounter < 30) {
this.reconnectCounter += 1;
this.reconnectTimeout = window.setTimeout(
this.start, 1000
);
} else {
this.stopReconnect();
throw new Error('HomeHubWebSocket: Reconnect tries limit reached.');
}
}
start = () => {
this.socket = new WebSocket(this.settings.url, this.settings.protocols);
this.socket.addEventListener('open', this.onSocketOpen);
this.socket.addEventListener('close', this.onSocketClose);
this.socket.addEventListener('message', this.onSocketMessage);
}
stop () {
this.socket.close();
}
onSocketOpen = () => {
this.logDebug('HomeHubWebSocket.onSocketOpen()', this.socket.readyState);
this.stopReconnect();
this.fireEvent('start');
}
onSocketClose = (event) => {
this.logDebug('HomeHubWebSocket.onSocketClose()', event.code);
this.startReconnect();
this.fireEvent('stop');
}
onSocketMessage = (event) => {
let message = null;
try {
message = JSON.parse(event.data);
this.logDebug('HomeHubWebSocket.onSocketMessage()', message);
} catch (error) {
console.error(
'HomeHubWebSocket.onSocketMessage() JSON Parse Error', error
);
return;
}
this.notify(message);
}
}

View File

@@ -0,0 +1,337 @@
import React from 'react';
import {v4 as uuidv4} from 'uuid';
import {DEFAULT_DASHBOARDS_CONTEXT, DashboardsContext} from 'src/context';
import {
DashboardFactory, DummyService, kWebSocketMsgTypeServiceNotification,
kWebSocketMsgTypeStateNotification, lookupService, lookupServices, sha256,
} from 'src/lib';
import * as WebSocketLib from 'src/lib/websocket';
export class DashboardsProvider extends React.PureComponent {
constructor (props) {
super(props);
this.webSocket = null;
this.webSocketUnsubscriber = null;
this.dashboardsHash = null;
this.state = {
currentDashboardId: DEFAULT_DASHBOARDS_CONTEXT.currentDashboardId,
dashboards: DEFAULT_DASHBOARDS_CONTEXT.dashboards,
isLoading: DEFAULT_DASHBOARDS_CONTEXT.isLoading,
lastSaveTimestamp: DEFAULT_DASHBOARDS_CONTEXT.lastSaveTimestamp,
lastSaveError: DEFAULT_DASHBOARDS_CONTEXT.lastSaveError,
isSaving: DEFAULT_DASHBOARDS_CONTEXT.isSaving,
isWebSocketConnected: DEFAULT_DASHBOARDS_CONTEXT.isWebSocketConnected,
};
}
handleSaveError = (error) => {
console.error('Dashboards save error!', error);
return error.message;
}
onWebSocketStart = () => {
this.setState({isWebSocketConnected: true});
}
onWebSocketStop = () => {
this.setState({isWebSocketConnected: false});
}
onWebSocketMessage = async (message) => {
if (message.type === kWebSocketMsgTypeServiceNotification) {
if (message.instance) {
const service = lookupService(
this.state.dashboards, message.kind, message.instance
);
if (!service.isDummy()) {
service.onMessage(message.data);
}
} else {
const services = lookupServices(this.state.dashboards, message.kind);
services.forEach((service) => {
if (!service.isDummy()) {
service.onMessage(message.data);
}
});
}
} else if (message.type == kWebSocketMsgTypeStateNotification) {
const dashboardsHash = await sha256(JSON.stringify(message.data));
if (this.dashboardsHash !== dashboardsHash) {
this.setState({
dashboards: await this.handleLoadedState(message.data),
lastSaveTimestamp: new Date(),
});
}
}
}
currentDashboard = () => {
return this.state.dashboards.find((dashboard) => {
return dashboard.id === this.state.currentDashboardId;
});
}
mutateCurrentDashboardServices = (mutator) => {
return this.state.dashboards.map((dashboard) => {
if (dashboard.id === this.state.currentDashboardId) {
return {
...dashboard,
services: mutator(dashboard.services),
};
}
return dashboard;
});
}
serviceFromSpec = (spec) => {
const ServiceObject = this.props.settings.SERVICES[spec.kind] || DummyService;
const serviceObject = new ServiceObject({
...spec,
widgetComponent: (
this.props.settings.WIDGETS[ServiceObject.widget] || null
),
});
return serviceObject;
}
saveDashboards = async (dashboards) => {
const nextState = {
isSaving: false,
};
try {
const dashboardsToSave = {
dashboards: dashboards.map((dashboard) => {
return {
...dashboard,
services: dashboard.services.map((service) => {
return service.toJSON();
}),
};
}),
};
const dashboardsHash = await sha256(JSON.stringify(dashboardsToSave));
if (this.dashboardsHash !== dashboardsHash) {
this.dashboardsHash = dashboardsHash;
this.setState({
isSaving: true,
});
await this.props.saveDashboards(dashboardsToSave);
nextState.lastSaveTimestamp = new Date();
nextState.lastSaveError = null;
}
} catch (error) {
this.dashboardsHash = null;
nextState.lastSaveTimestamp = null;
nextState.lastSaveError = this.handleSaveError(error);
}
this.setState(nextState);
}
nukeService = (kind, instance) => {
const removedService = lookupService(
this.state.dashboards, kind, instance
);
removedService.stop();
const nextDashboards = this.mutateCurrentDashboardServices((services) => {
return services.filter((service) => {
return !(service.kind === kind && service.instance === instance);
});
});
this.saveDashboards(nextDashboards);
this.setState({
dashboards: nextDashboards,
});
}
saveServiceCharacteristics = (kind, instance, newCharacteristics) => {
const updatedService = lookupService(
this.state.dashboards, kind, instance
);
const nextDashboards = this.mutateCurrentDashboardServices((services) => {
return services.map((service) => {
if (service === updatedService) {
service.setCharacteristics(newCharacteristics);
}
return service;
});
});
this.saveDashboards(nextDashboards);
this.setState({
dashboards: nextDashboards,
});
}
saveServiceLayout = (instance, layout) => {
const nextDashboards = this.mutateCurrentDashboardServices((services) => {
return services.map((service) => {
if (service.instance === instance) {
service.setLayout(layout);
}
return service;
});
});
this.saveDashboards(nextDashboards);
this.setState({
dashboards: nextDashboards,
});
}
addService = (kind, characteristics) => {
const ServiceObject = this.props.settings.SERVICES[kind];
const WidgetComponent = this.props.settings.WIDGETS[ServiceObject.widget];
const spec = {
kind: kind,
instance: uuidv4(),
characteristics: characteristics,
};
const currentDashboard = this.currentDashboard();
if (!currentDashboard) {
return;
}
let lastLayout = {y: 0, h: 0};
currentDashboard.services.forEach((service) => {
if (service.layout.y >= lastLayout.y) {
lastLayout = {
...lastLayout,
y: service.layout.y,
};
if (service.layout.h > lastLayout.h) {
lastLayout.h = service.layout.h;
}
}
});
spec.layout = {
...WidgetComponent.defaultLayout || {h: 1, w: 1},
x: 0,
y: lastLayout.y + lastLayout.h,
};
const nextDashboards = this.mutateCurrentDashboardServices((services) => {
return [...services, this.serviceFromSpec(spec)];
});
this.saveDashboards(nextDashboards);
this.setState({
dashboards: nextDashboards,
});
}
handleLoadedState = async (state) => {
this.dashboardsHash = await sha256(JSON.stringify(state));
return state.dashboards.map((dashboard) => {
return {
...dashboard,
services: dashboard.services.map((service) => {
return this.serviceFromSpec(service);
}),
};
});
}
loadDashboards = async () => {
const nextState = {
isLoading: false,
};
try {
const state = await this.props.loadDashboards();
nextState.dashboards = await this.handleLoadedState(state);
if (this.state.currentDashboardId === null && nextState.dashboards.length > 0) {
nextState.currentDashboardId = nextState.dashboards[0].id;
}
nextState.lastSaveTimestamp = new Date();
} catch (error) {
nextState.lastSaveError = this.handleSaveError(error);
}
this.setState(nextState);
}
setCurrentDashboardId = (dashboardId) => {
this.setState({
currentDashboardId: dashboardId,
});
}
addDashboard = (name) => {
const nextDashboards = [
...this.state.dashboards,
DashboardFactory(name),
];
const nextState = {
dashboards: nextDashboards,
};
if (nextDashboards.length == 1) {
nextState.currentDashboardId = nextDashboards[0].id;
}
this.saveDashboards(nextDashboards);
this.setState(nextState);
}
componentDidMount () {
this.setState({
isLoading: true,
});
this.webSocket = new WebSocketLib.HomeHubWebSocket(
this.props.settings.DEBUG, this.props.settings.WEBSOCKET
);
this.webSocket.addEventListener('start', this.onWebSocketStart);
this.webSocket.addEventListener('stop', this.onWebSocketStop);
this.webSocketUnsubscriber = this.webSocket.subscribe(
this.onWebSocketMessage
);
this.loadDashboards();
if (!this.props.settings.OFFLINE_MODE) {
this.webSocket.start();
}
}
componentWillUnmount () {
if (this.webSocketUnsubscriber) {
this.webSocketUnsubscriber();
this.webSocketUnsubscriber = null;
}
if (this.webSocket) {
this.webSocket.removeEventListener('start', this.onWebSocketStart);
this.webSocket.removeEventListener('stop', this.onWebSocketStop);
this.webSocket.stop();
}
}
render () {
const contextValue = {
currentDashboardId: this.state.currentDashboardId,
dashboards: this.state.dashboards,
nukeService: this.nukeService,
saveServiceCharacteristics: this.saveServiceCharacteristics,
saveServiceLayout: this.saveServiceLayout,
addService: this.addService,
isLoading: this.state.isLoading,
lastSaveTimestamp: this.state.lastSaveTimestamp,
lastSaveError: this.state.lastSaveError,
isSaving: this.state.isSaving,
isWebSocketConnected: this.state.isWebSocketConnected,
setCurrentDashboardId: this.setCurrentDashboardId,
dashboardsHash: this.dashboardsHash,
addDashboard: this.addDashboard,
};
return (
<DashboardsContext.Provider value={contextValue}>
{this.state.isLoading && this.props.loader}
{!this.state.isLoading && this.props.children}
</DashboardsContext.Provider>
);
}
}

View File

@@ -0,0 +1 @@
export * from './DashboardsProvider';

View File

@@ -0,0 +1,11 @@
require('regenerator-runtime/runtime');
require('whatwg-fetch');
window.crypto = {
getRandomValues: require('polyfill-crypto.getrandomvalues'),
};
require('tests/__setup__/enzyme.setup.js');
require('tests/__setup__/jasmine.setup.js');
let testsContext = require.context('.', true, /\.spec\.js$/);
testsContext.keys().forEach(testsContext);

View File

@@ -0,0 +1,51 @@
import {FakeWidget, FakeService} from 'tests/__fixtures__/services';
export const DashboardsFactory = () => {
return [
{
id: 'testing',
name: 'Testing',
services: [
new FakeService({
instance: 'fake_instance',
widgetComponent: FakeWidget,
characteristics: {
spam: true,
},
layout: {
x: 0,
y: 0,
w: 1,
h: 1,
},
}),
new FakeService({
instance: 'other_fake_instance',
widgetComponent: FakeWidget,
characteristics: {
spam: true,
},
layout: {
x: 0,
y: 1,
w: 1,
h: 1,
},
}),
],
},
];
};
export const DashboardsJSONFactory = (dashboards) => {
dashboards = dashboards || DashboardsFactory();
return dashboards.map((dashboard) => {
return {
...dashboard,
services: dashboard.services.map((service) => {
return service.toJSON();
}),
};
});
};

View File

@@ -0,0 +1,19 @@
import React from 'react';
import {BaseService} from 'src/lib/services';
export const FakeWidget = (props) => { // eslint-disable-line no-unused-vars
return <span>FakeWidget</span>;
};
export const FakeWidgetSettingsView = (props) => { // eslint-disable-line no-unused-vars
return <span>FakeWidgetSettingsView</span>;
};
FakeWidget.defaultLayout = {w: 1, h: 1};
FakeWidget.settingsView = FakeWidgetSettingsView;
export class FakeService extends BaseService {
static kind = 'FakeService';
static widget = 'FakeWidget';
}

View File

@@ -0,0 +1,7 @@
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({
adapter: new Adapter(),
disableLifecycleMethods: true,
});

View File

@@ -0,0 +1,22 @@
/* eslint-disable no-unused-vars */
jasmine.getEnv().beforeAll(() => {
jasmine.addMatchers({
toBeUUIDv4: (util, customEqualityTesters) => {
return {
compare: (actual, expected) => {
const reUUIDv4 = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[a-f0-9]{4}-[a-f0-9]{12}$/;
const result = {};
result.pass = reUUIDv4.test(actual);
if (!result.pass) {
result.message = `Expected ${actual} to be a UUIDv4`;
}
return result;
},
};
},
});
});

View File

@@ -0,0 +1,51 @@
import * as Services from 'src/api/services';
import * as RPC from 'src/lib/rpc';
describe('src/api/services', () => {
beforeEach(() => {
spyOn(RPC, 'callMethod').and.resolveTo('ok');
});
describe('start', () => {
it('calls and RPC method to start a service', async () => {
// When
const result = await Services.start('FakeService', 'fake_instance', {
'spam': true,
});
// Then
expect(result).toEqual('ok');
expect(RPC.callMethod).toHaveBeenCalledWith('services.start', [
'FakeService', 'fake_instance', {'spam': true},
]);
});
});
describe('stop', () => {
it('calls and RPC method to stop a service', async () => {
// When
const result = await Services.stop('FakeService', 'fake_instance');
// Then
expect(result).toEqual('ok');
expect(RPC.callMethod).toHaveBeenCalledWith('services.stop', [
'FakeService', 'fake_instance',
]);
});
});
describe('use', () => {
it('calls and RPC method to use a service capability', async () => {
// When
const result = await Services.use(
'FakeService', 'fake_instance', 'testing', ['spam'],
);
// Then
expect(result).toEqual('ok');
expect(RPC.callMethod).toHaveBeenCalledWith('services.use', [
'FakeService', 'fake_instance', 'testing', ['spam'],
]);
});
});
});

View File

@@ -0,0 +1,32 @@
import * as State from 'src/api/state';
import * as RPC from 'src/lib/rpc';
describe('src/api/state', () => {
beforeEach(() => {
spyOn(RPC, 'callMethod').and.resolveTo('ok');
});
describe('get', () => {
it('calls and RPC method to get frontend state', async () => {
// When
const result = await State.get();
// Then
expect(result).toEqual('ok');
expect(RPC.callMethod).toHaveBeenCalledWith('state.get_frontend');
});
});
describe('save', () => {
it('calls and RPC method to save frontend state', async () => {
// When
const result = await State.save('spam');
// Then
expect(result).toEqual('ok');
expect(RPC.callMethod).toHaveBeenCalledWith(
'state.save_frontend', ['spam']
);
});
});
});

View File

@@ -0,0 +1,750 @@
/* eslint-disable no-unused-vars */
import {shallow} from 'enzyme';
import React from 'react';
import {withContext} from 'shallow-with-context';
import {ServiceContainer} from 'src/containers/ServiceContainer';
import {DEFAULT_DASHBOARDS_CONTEXT} from 'src/context/DashboardsContext';
import {DummyService, ServiceState} from 'src/lib/services';
import {DashboardsFactory} from 'tests/__fixtures__/dashboards';
describe('src/containers/ServiceContainer', () => {
const ServiceContainerWithContext = withContext(
ServiceContainer, DEFAULT_DASHBOARDS_CONTEXT
);
let context = null;
beforeEach(() => {
context = {
...DEFAULT_DASHBOARDS_CONTEXT,
nukeService: jasmine.createSpy(),
saveServiceCharacteristics: jasmine.createSpy(),
saveServiceLayout: jasmine.createSpy(),
addService: jasmine.createSpy(),
setCurrentDashboardId: jasmine.createSpy(),
addDashboard: jasmine.createSpy(),
dashboards: DashboardsFactory(),
};
});
describe('constructor', () => {
it('initializes the state', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
// Then
expect(component.state('serviceState')).toBe(null);
expect(component.state('showSettingsModal')).toBe(false);
expect(component.state('nextCharacteristics')).toBe(null);
});
});
describe('service', () => {
it('looks up the bound service instance', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
// When
const service = component.instance().service();
// Then
expect(service).toEqual(context.dashboards[0].services[0]);
});
});
describe('setServiceState', () => {
it('initializes new state object if the current state is `null`', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
// When
component.instance().setServiceState({data: {spam: true}}, {});
// Then
expect(component.state('serviceState')).toBeInstanceOf(ServiceState);
expect(component.state('serviceState').data()).toEqual({spam: true});
});
it('initializes new state object if reset is requested', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
const oldState = new ServiceState({data: {spam: false}});
component.setState({serviceState: oldState});
// When
component.instance().setServiceState(
{data: {spam: true}}, {reset: true}
);
// Then
expect(component.state('serviceState')).not.toBe(oldState);
});
it('updates the existing state object with new payload', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
const oldState = new ServiceState({data: {spam: false}});
component.setState({serviceState: oldState});
// When
component.instance().setServiceState({data: {spam: true}}, {});
// Then
expect(component.state('serviceState')).not.toBe(oldState);
expect(component.state('serviceState').data()).toEqual({spam: true});
});
});
it('allows setting next characteristics', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
// When
component.instance().setNextCharacteristics({spam: true});
// Then
expect(component.state('nextCharacteristics')).toEqual({spam: true});
});
describe('setNextCharacteristicsFromService', () => {
it('resets the next characteristics if the service is null', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
component.setState({nextCharacteristics: {spam: false}});
// When
component.instance().setNextCharacteristicsFromService(null);
// Then
expect(component.state('nextCharacteristics')).toBe(null);
});
it('resets the next characteristics if the service has no widget', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
component.setState({nextCharacteristics: {spam: false}});
const service = context.dashboards[0].services[0];
service.widgetComponent = null;
// When
component.instance().setNextCharacteristicsFromService(service);
// Then
expect(component.state('nextCharacteristics')).toBe(null);
});
it('resets the next characteristics if the service is dummy', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
component.setState({nextCharacteristics: {spam: false}});
const service = new DummyService();
// When
component.instance().setNextCharacteristicsFromService(service);
// Then
expect(component.state('nextCharacteristics')).toBe(null);
});
it('sets the next characteristics from the service characteristics', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
const service = context.dashboards[0].services[0];
// When
component.instance().setNextCharacteristicsFromService(service);
// Then
expect(component.state('nextCharacteristics')).toEqual(
service.characteristics
);
});
});
describe('showHideSettingsModal', () => {
it('sets next characteristics from the service when modal is to be shown', () => {
// Givem
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
spyOn(component.instance(), 'setNextCharacteristicsFromService');
// When
component.instance().showHideSettingsModal(true);
// Then
expect(component.instance().setNextCharacteristicsFromService).toHaveBeenCalledWith(
context.dashboards[0].services[0]
);
});
it('resets next characteristics when modal is to be hidden', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
spyOn(component.instance(), 'setNextCharacteristicsFromService');
// When
component.instance().showHideSettingsModal(false);
// Then
expect(component.instance().setNextCharacteristicsFromService).toHaveBeenCalledWith(
null
);
});
it('updates the state with new visibility flag', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
spyOn(component.instance(), 'setNextCharacteristicsFromService');
// When
component.instance().showHideSettingsModal(true);
// Then
expect(component.state('showSettingsModal')).toBe(true);
});
});
describe('onSettingsButtonClick', () => {
it('requests the settings modal to be shown', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
spyOn(component.instance(), 'showHideSettingsModal');
// When
component.instance().onSettingsButtonClick();
// Then
expect(component.instance().showHideSettingsModal).toHaveBeenCalledWith(
true
);
});
});
describe('onSettingsModalClose', () => {
it('requests the settings modal to be hidden', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
spyOn(component.instance(), 'showHideSettingsModal');
// When
component.instance().onSettingsModalClose();
// Then
expect(component.instance().showHideSettingsModal).toHaveBeenCalledWith(
false
);
});
});
describe('onSettingsModalSaveButtonClick', () => {
it('requests the next characteristics to be saved', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
component.setState({nextCharacteristics: {spam: true}});
// When
component.instance().onSettingsModalSaveButtonClick();
// Then
expect(context.saveServiceCharacteristics).toHaveBeenCalledWith(
'FakeService', 'fake_instance', {spam: true}
);
});
it('closes the settings modal', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
spyOn(component.instance(), 'onSettingsModalClose');
// When
component.instance().onSettingsModalSaveButtonClick();
// Then
expect(component.instance().onSettingsModalClose).toHaveBeenCalled();
});
});
describe('onSettingsModalNukeButtonClick', () => {
it('requests the service to be nuked', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
// When
component.instance().onSettingsModalNukeButtonClick();
// Then
expect(context.nukeService).toHaveBeenCalledWith(
'FakeService', 'fake_instance'
);
});
it('closes the settings modal', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
spyOn(component.instance(), 'onSettingsModalClose');
// When
component.instance().onSettingsModalNukeButtonClick();
// Then
expect(component.instance().onSettingsModalClose).toHaveBeenCalled();
});
});
describe('onAppearancePopupColorChange', () => {
it('requests the updated characteristics to be saved', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
// When
component.instance().onAppearancePopupColorChange('red');
// Then
expect(context.saveServiceCharacteristics).toHaveBeenCalledWith(
'FakeService', 'fake_instance', {
spam: true,
appearance: {
color: 'red',
},
}
);
});
});
describe('onServiceRestartButtonClick', () => {
it('requests the updated characteristics to be saved', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
const service = context.dashboards[0].services[0];
spyOn(service, 'restart');
// When
component.instance().onServiceRestartButtonClick();
// Then
expect(service.restart).toHaveBeenCalled();
});
});
describe('unsubscribeIfNeeded', () => {
it('calls the unsubscribe callback', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
component.instance().serviceUnsubscribe = jasmine.createSpy();
// When
component.instance().unsubscribeIfNeeded();
// Then
expect(component.instance().serviceUnsubscribe).toHaveBeenCalled();
});
});
describe('componentDidMount', () => {
it('is a noop if the service is null', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
spyOn(component.instance(), 'setServiceState');
spyOn(component.instance(), 'unsubscribeIfNeeded');
spyOn(component.instance(), 'service').and.returnValue(null);
// When
component.instance().componentDidMount();
// Then
expect(component.instance().setServiceState).not.toHaveBeenCalled();
expect(component.instance().unsubscribeIfNeeded).not.toHaveBeenCalled();
expect(component.instance().serviceUnsubscribe).toBe(null);
});
it('is a noop if the service has no widget', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
spyOn(component.instance(), 'setServiceState');
spyOn(component.instance(), 'unsubscribeIfNeeded');
context.dashboards[0].services[0].widgetComponent = null;
// When
component.instance().componentDidMount();
// Then
expect(component.instance().setServiceState).not.toHaveBeenCalled();
expect(component.instance().unsubscribeIfNeeded).not.toHaveBeenCalled();
expect(component.instance().serviceUnsubscribe).toBe(null);
});
it('is a noop if the service is dummy', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
spyOn(component.instance(), 'setServiceState');
spyOn(component.instance(), 'unsubscribeIfNeeded');
spyOn(component.instance(), 'service').and.returnValue(
new DummyService()
);
// When
component.instance().componentDidMount();
// Then
expect(component.instance().setServiceState).not.toHaveBeenCalled();
expect(component.instance().unsubscribeIfNeeded).not.toHaveBeenCalled();
expect(component.instance().serviceUnsubscribe).toBe(null);
});
it('sets itself up for the service', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
spyOn(component.instance(), 'setServiceState');
spyOn(component.instance(), 'unsubscribeIfNeeded');
const service = context.dashboards[0].services[0];
spyOn(service, 'start');
const initialState = new ServiceState({data: {initial: true}});
spyOn(service, 'initialState').and.returnValue(initialState);
const fakeUnsubscribe = jasmine.createSpy();
spyOn(service, 'subscribe').and.returnValue(fakeUnsubscribe);
// When
component.instance().componentDidMount();
// Then
expect(component.instance().setServiceState).toHaveBeenCalledWith(
initialState, true
);
expect(service.start).toHaveBeenCalled();
expect(component.instance().unsubscribeIfNeeded).toHaveBeenCalled();
expect(service.subscribe).toHaveBeenCalledWith(
component.instance().setServiceState
);
expect(component.instance().serviceUnsubscribe).toEqual(fakeUnsubscribe);
});
});
describe('componentWillUnmount', () => {
it('unsubscribes from the service', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{(props) => <span>It works!</span>}
</ServiceContainerWithContext>,
{
context: context,
}
);
const mockUnsubscribeIfNeeded = jasmine.createSpy();
component.instance().unsubscribeIfNeeded = mockUnsubscribeIfNeeded;
// When
component.unmount();
// Then
expect(mockUnsubscribeIfNeeded).toHaveBeenCalled();
});
});
describe('render', () => {
let childrenFunc = null;
beforeEach(() => {
childrenFunc = jasmine.createSpy();
childrenFunc.returnValue = <span>It works!</span>;
});
it('calls the children func with props', () => {
// Given
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{childrenFunc}
</ServiceContainerWithContext>,
{
context: context,
}
);
const serviceState = new ServiceState({data: {spam: true}});
component.setState({
serviceState: serviceState,
showSettingsModal: true,
nextCharacteristics: {eggs: true},
});
// Then
expect(childrenFunc).toHaveBeenCalledWith({
hasSettingsView: true,
service: context.dashboards[0].services[0],
serviceState: serviceState,
setServiceState: component.instance().setServiceState,
settingsViewProps: {
kind: 'FakeService',
instance: 'fake_instance',
nextCharacteristics: {eggs: true},
setNextCharacteristics: component.instance().setNextCharacteristics,
},
showSettingsModal: true,
onAppearancePopupColorChange: component.instance().onAppearancePopupColorChange,
onServiceRestartButtonClick: component.instance().onServiceRestartButtonClick,
onSettingsButtonClick: component.instance().onSettingsButtonClick,
onSettingsModalClose: component.instance().onSettingsModalClose,
onSettingsModalNukeButtonClick: component.instance().onSettingsModalNukeButtonClick,
onSettingsModalSaveButtonClick: component.instance().onSettingsModalSaveButtonClick,
});
});
it('disables settings view if the service is dummy', () => {
// Given
const service = context.dashboards[0].services[0];
spyOn(service, 'isDummy').and.returnValue(true);
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{childrenFunc}
</ServiceContainerWithContext>,
{
context: context,
}
);
// Then
expect(childrenFunc).toHaveBeenCalledWith(jasmine.objectContaining({
hasSettingsView: false,
}));
});
it('disables settings view if the service has no widget', () => {
// Given
const service = context.dashboards[0].services[0];
service.widgetComponent = null;
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{childrenFunc}
</ServiceContainerWithContext>,
{
context: context,
}
);
// Then
expect(childrenFunc).toHaveBeenCalledWith(jasmine.objectContaining({
hasSettingsView: false,
}));
});
it('disables settings view if the service widget has no settings view', () => {
// Given
const service = context.dashboards[0].services[0];
const oldSettingsView = service.widgetComponent.settingsView;
service.widgetComponent.settingsView = undefined;
const component = shallow(
<ServiceContainerWithContext kind="FakeService" instance="fake_instance">
{childrenFunc}
</ServiceContainerWithContext>,
{
context: context,
}
);
// Then
expect(childrenFunc).toHaveBeenCalledWith(jasmine.objectContaining({
hasSettingsView: false,
}));
// After
service.widgetComponent.settingsView = oldSettingsView;
});
});
});

View File

@@ -0,0 +1,304 @@
/* eslint-disable no-unused-vars */
import {mount} from 'enzyme';
import React from 'react';
import {
DEFAULT_DASHBOARDS_CONTEXT, DashboardsContext,
} from 'src/context/DashboardsContext';
import * as Hooks from 'src/hooks';
import {DashboardsFactory} from 'tests/__fixtures__/dashboards';
describe('src/hooks', () => {
const HookWrapper = ({context, hook, hookArgs, output}) => {
const Children = (props) => {
output.result = hook(...hookArgs);
return <span>It works!</span>;
};
return (
<DashboardsContext.Provider value={context}>
<Children />
</DashboardsContext.Provider>
);
};
let context = null;
beforeEach(() => {
context = {
...DEFAULT_DASHBOARDS_CONTEXT,
dashboards: DashboardsFactory(),
};
});
describe('useServices', () => {
it('returns the the current dashboard services', () => {
// Given
const output = {};
context = {
...context,
currentDashboardId: context.dashboards[0].id,
};
// When
const component = mount(
<HookWrapper
context={context}
hook={Hooks.useServices}
hookArgs={[]}
output={output}
/>
);
// Then
expect(output.result).toEqual(context.dashboards[0].services);
});
});
describe('useService', () => {
it('returns the the specified service instance', () => {
// Given
const output = {};
// When
const component = mount(
<HookWrapper
context={context}
hook={Hooks.useService}
hookArgs={['FakeService', 'fake_instance']}
output={output}
/>
);
// Then
expect(output.result).toEqual(context.dashboards[0].services[0]);
});
});
describe('useSaveServiceCharacteristics', () => {
it('returns the saveServiceCharacteristics callback', () => {
// Given
const output = {};
// WHen
const component = mount(
<HookWrapper
context={context}
hook={Hooks.useSaveServiceCharacteristics}
hookArgs={[]}
output={output}
/>
);
// Then
expect(output.result).toBe(context.saveServiceCharacteristics);
});
});
describe('useSaveServiceLayout', () => {
it('returns the saveServiceLayout callback', () => {
// Given
const output = {};
// When
const component = mount(
<HookWrapper
context={context}
hook={Hooks.useSaveServiceLayout}
hookArgs={[]}
output={output}
/>
);
// Then
expect(output.result).toBe(context.saveServiceLayout);
});
});
describe('useNukeService', () => {
it('returns the nukeService callback', () => {
// Given
const output = {};
// When
const component = mount(
<HookWrapper
context={context}
hook={Hooks.useNukeService}
hookArgs={[]}
output={output}
/>
);
// Then
expect(output.result).toBe(context.nukeService);
});
});
describe('useAddService', () => {
it('returns the addService callback', () => {
// Given
const output = {};
// When
const component = mount(
<HookWrapper
context={context}
hook={Hooks.useAddService}
hookArgs={[]}
output={output}
/>
);
// Then
expect(output.result).toBe(context.addService);
});
});
describe('useServicesSavingState', () => {
it('returns the service saving state', () => {
// Given
const output = {};
// When
const component = mount(
<HookWrapper
context={context}
hook={Hooks.useServicesSavingState}
hookArgs={[]}
output={output}
/>
);
// Then
expect(output.result).toEqual({
lastSaveTimestamp: context.lastSaveTimestamp,
lastSaveError: context.lastSaveError,
isSaving: context.isSaving,
});
});
});
describe('useWebSocketState', () => {
it('returns the websocket state', () => {
// Given
const output = {};
// When
const component = mount(
<HookWrapper
context={context}
hook={Hooks.useWebSocketState}
hookArgs={[]}
output={output}
/>
);
// Then
expect(output.result).toEqual({
isWebSocketConnected: context.isWebSocketConnected,
});
});
});
describe('useDashboards', () => {
it('returns the dashboards', () => {
// Given
const output = {};
// When
const component = mount(
<HookWrapper
context={context}
hook={Hooks.useDashboards}
hookArgs={[]}
output={output}
/>
);
// Then
expect(output.result).toEqual(context.dashboards);
});
});
describe('useCurrentDashboardId', () => {
it('returns the dashboards', () => {
// Given
const output = {};
// When
const component = mount(
<HookWrapper
context={context}
hook={Hooks.useCurrentDashboardId}
hookArgs={[]}
output={output}
/>
);
// Then
expect(output.result).toEqual(context.currentDashboardId);
});
});
describe('useSetCurrentDashboardId', () => {
it('returns the setCurrentDashboardId callback', () => {
// Given
const output = {};
// When
const component = mount(
<HookWrapper
context={context}
hook={Hooks.useSetCurrentDashboardId}
hookArgs={[]}
output={output}
/>
);
// Then
expect(output.result).toBe(context.setCurrentDashboardId);
});
});
describe('useDashboardsHash', () => {
it('returns the dashboards hash', () => {
// Given
const output = {};
// When
const component = mount(
<HookWrapper
context={context}
hook={Hooks.useDashboardsHash}
hookArgs={[]}
output={output}
/>
);
// Then
expect(output.result).toEqual(context.dashboardsHash);
});
});
describe('useAddDashboard', () => {
it('returns the addDashboard callback', () => {
// Given
const output = {};
// When
const component = mount(
<HookWrapper
context={context}
hook={Hooks.useAddDashboard}
hookArgs={[]}
output={output}
/>
);
// Then
expect(output.result).toBe(context.addDashboard);
});
});
});

View File

@@ -0,0 +1,242 @@
import * as BaseLib from 'src/lib/base';
describe('src/lib/base', () => {
describe('SubscribableMixin', () => {
const TestClass = class extends BaseLib.SubscribableMixin(BaseLib.HomeHubBaseClass) {
};
describe('constructor', () => {
it('initializes the instance', () => {
// Given
const mixin = new TestClass();
// Then
expect(mixin.subscribers).toEqual([]);
});
});
describe('notify', () => {
it('notifies the subscribers', () => {
// Given
const mixin = new TestClass();
const mockSubscriber = jasmine.createSpy();
mixin.subscribers.push(mockSubscriber);
mixin.subscribers.push(null);
// When
mixin.notify('test');
// Then
expect(mockSubscriber).toHaveBeenCalledWith('test', {});
});
it('allows sending a notification with user info', () => {
// Given
const mixin = new TestClass();
const mockSubscriber = jasmine.createSpy();
mixin.subscribers.push(mockSubscriber);
// When
mixin.notify('test', {reset: true});
// Then
expect(mockSubscriber).toHaveBeenCalledWith('test', {reset: true});
});
});
describe('unsubscriberFactory', () => {
it('returns an unsubscriber function', () => {
// Given
const mixin = new TestClass();
// When
const result = mixin.unsubscriberFactory(0);
// Then
expect(result).toBeInstanceOf(Function);
});
it('configures the unsubscriber function to remove the bound subscriber', () => {
// Given
const mixin = new TestClass();
const mockSubscriber1 = jasmine.createSpy();
mixin.subscribers.push(mockSubscriber1);
const mockSubscriber2 = jasmine.createSpy();
mixin.subscribers.push(mockSubscriber2);
const mockSubscriber3 = jasmine.createSpy();
mixin.subscribers.push(mockSubscriber3);
const unsubscriber = mixin.unsubscriberFactory(1);
// When
unsubscriber();
// Then
expect(mixin.subscribers.length).toEqual(3);
expect(mixin.subscribers[0]).toBe(mockSubscriber1);
expect(mixin.subscribers[1]).toBe(null);
expect(mixin.subscribers[2]).toBe(mockSubscriber3);
});
});
describe('subscribe', () => {
it('adds a subscriber and returns its unsubscriber', () => {
// Given
const mixin = new TestClass();
const mockUnsubscriber = jasmine.createSpy();
spyOn(mixin, 'unsubscriberFactory').and.returnValue = mockUnsubscriber;
const mockSubscriber = jasmine.createSpy();
// When
mixin.subscribe(mockSubscriber);
// Then
expect(mixin.subscribers.length).toEqual(1);
expect(mixin.subscribers[0]).toBe(mockSubscriber);
expect(mixin.unsubscriberFactory).toHaveBeenCalledWith(0);
});
});
});
describe('EventSouceMixin', () => {
const Base = BaseLib.EventSourceMixin(BaseLib.HomeHubBaseClass, ['start']);
const TestClass = class extends Base {
};
describe('constructor', () => {
it('initializes the instance', () => {
// Given
const mixin = new TestClass();
// Then
expect(mixin.eventListeners).toEqual({start: []});
});
});
describe('addEventListener', () => {
let mockListener = null;
beforeEach(() => {
mockListener = jasmine.createSpy();
});
it('ignores an uknown event', () => {
// Given
const mixin = new TestClass();
// When
mixin.addEventListener('testing', mockListener);
// Then
expect(mixin.eventListeners.testing).not.toBeDefined();
});
it('adds a listener for an event', () => {
// Given
const mixin = new TestClass();
// When
mixin.addEventListener('start', mockListener);
// Then
expect(mixin.eventListeners.start.length).toEqual(1);
expect(mixin.eventListeners.start[0]).toBe(mockListener);
});
});
describe('addEventListener', () => {
let mockListener = null;
beforeEach(() => {
mockListener = jasmine.createSpy();
});
it('ignores an uknown event', () => {
// Given
const mixin = new TestClass();
mixin.addEventListener('start', mockListener);
// When
mixin.removeEventListener('testing', mockListener);
// Then
expect(mixin.eventListeners.testing).not.toBeDefined();
});
it('ignores an unknown listener', () => {
// Given
const mixin = new TestClass();
mixin.addEventListener('start', mockListener);
const mockListener2 = jasmine.createSpy();
// When
mixin.removeEventListener('start', mockListener2);
// Then
expect(mixin.eventListeners.start.length).toEqual(1);
expect(mixin.eventListeners.start[0]).toBe(mockListener);
});
it('removes a listener for an event', () => {
// Given
const mixin = new TestClass();
mixin.addEventListener('start', mockListener);
const mockListener2 = jasmine.createSpy();
mixin.addEventListener('start', mockListener2);
const mockListener3 = jasmine.createSpy();
mixin.addEventListener('start', mockListener3);
// When
mixin.removeEventListener('start', mockListener2);
// Then
expect(mixin.eventListeners.start.length).toEqual(3);
expect(mixin.eventListeners.start[0]).toBe(mockListener);
expect(mixin.eventListeners.start[1]).toBe(null);
expect(mixin.eventListeners.start[2]).toBe(mockListener3);
});
});
describe('fireEvent', () => {
let mockListener = null;
beforeEach(() => {
mockListener = jasmine.createSpy();
});
it('ignores an uknown event', () => {
// Given
const mixin = new TestClass();
mixin.addEventListener('start', mockListener);
// When
mixin.fireEvent('testing', mockListener);
// Then
expect(mockListener).not.toHaveBeenCalled();
});
it('calls event listeners for an event', () => {
// Given
const mixin = new TestClass();
mixin.addEventListener('start', mockListener);
// When
mixin.fireEvent('start', mockListener);
// Then
expect(mockListener).toHaveBeenCalledWith(mixin);
});
});
});
});

View File

@@ -0,0 +1,23 @@
import * as DashboardsLib from 'src/lib/dashboards';
describe('src/lib/dashboards', () => {
describe('DashboardFactory', () => {
it('initializes the dashboard with defaults', () => {
// Given
const result = DashboardsLib.DashboardFactory();
// Then
expect(result.name).toEqual(jasmine.any(String));
expect(result.id).toBeUUIDv4();
expect(result.services).toEqual([]);
});
it('allows specifying an arbitrary name', () => {
// Given
const result = DashboardsLib.DashboardFactory('Testing');
// Then
expect(result.name).toEqual('Testing');
});
});
});

View File

@@ -0,0 +1,15 @@
import * as HashLib from 'src/lib/hashlib';
describe('src/lib/hashlib', () => {
describe('sha256', () => {
it('generates a hex digest of a message', async () => {
// Given
const result = await HashLib.sha256('spam');
// Then
expect(result).toEqual(
'f10c5a90947d4313c6af600facfb0c259444e4932a217fc341be0b3776f40933'
);
});
});
});

View File

@@ -0,0 +1,48 @@
import * as LocalStorageLib from 'src/lib/localStorage';
describe('src/lib/localStorage', () => {
beforeEach(() => {
window.localStorage.setItem('test', '{"spam":true}');
});
afterEach(() => {
window.localStorage.removeItem('test');
});
describe('getItem', () => {
it('returns the parsed item', () => {
// Given
const result = LocalStorageLib.getItem('test');
// Then
expect(result).toEqual({'spam': true});
});
it('returns default if the item is not present', () => {
// Given
const result = LocalStorageLib.getItem('test2', 'default');
// Then
expect(result).toEqual('default');
});
it('defaults the default to null', () => {
// Given
const result = LocalStorageLib.getItem('test3');
// Then
expect(result).toBe(null);
});
});
describe('setItem', () => {
it('stores the serialized item', () => {
// Given
LocalStorageLib.setItem('test', {spam: false});
// Then
const storedItem = window.localStorage.getItem('test');
expect(storedItem).toEqual('{"spam":false}');
});
});
});

View File

@@ -0,0 +1,117 @@
import * as RPCLib from 'src/lib/rpc';
describe('src/lib/rpc', () => {
describe('callMethod', () => {
beforeEach(() => {
spyOn(window, 'fetch');
});
it('formats and sends a POST request to the backend RPC URL', async () => {
// Given
window.fetch.and.resolveTo({
ok: true,
status: 200,
statusText: 'OK',
json: jasmine.createSpy().and.resolveTo({
result: 'ok',
}),
});
// When
await RPCLib.callMethod('testing', ['spam']);
// Then
expect(window.fetch).toHaveBeenCalledWith('/backend/rpc', {
method: 'POST',
body: jasmine.any(String),
headers: {
'Content-Type': 'application/json',
},
});
const callArgs = window.fetch.calls.argsFor(0);
const body = JSON.parse(callArgs[1].body);
expect(body.jsonrpc).toEqual('2.0');
expect(body.method).toEqual('testing');
expect(body.params).toEqual(['spam']);
expect(body.id).toBeUUIDv4();
});
it('formats a notification call', async () => {
// Given
window.fetch.and.resolveTo({
ok: true,
status: 200,
statusText: 'OK',
json: jasmine.createSpy().and.resolveTo({
result: 'ok',
}),
});
// When
await RPCLib.callMethod('testing', ['spam'], true);
// Then
const callArgs = window.fetch.calls.argsFor(0);
const body = JSON.parse(callArgs[1].body);
expect(body.id).not.toBeDefined();
});
it('returns an error is the response was not OK', async () => {
// Given
window.fetch.and.resolveTo({
ok: false,
status: 400,
statusText: 'Bad Request',
});
// When
const result = await RPCLib.callMethod('testing');
// Then
expect(result).not.toContain('data');
expect(result.error).toMatch('HTTP 400 Bad Request');
});
it('returns an error is the response was a JSONRPC error', async () => {
// Given
window.fetch.and.resolveTo({
ok: true,
status: 200,
statusText: 'OK',
json: jasmine.createSpy().and.resolveTo({
error: {
message: 'Internal error',
data: 'Test',
},
}),
});
// When
const result = await RPCLib.callMethod('testing');
// Then
expect(result).not.toContain('data');
expect(result.error).toMatch('RPC Error: Internal error Test');
});
it('returns an result is the response was successful', async () => {
// Given
window.fetch.and.resolveTo({
ok: true,
status: 200,
statusText: 'OK',
json: jasmine.createSpy().and.resolveTo({
result: 'ok',
}),
});
// When
const result = await RPCLib.callMethod('testing');
// Then
expect(result.data).toEqual('ok');
expect(result).not.toContain('error');
});
});
});

View File

@@ -0,0 +1,495 @@
import * as ServicesLib from 'src/lib/services';
import {DashboardsFactory} from 'tests/__fixtures__/dashboards';
import {FakeService, FakeWidget} from 'tests/__fixtures__/services';
describe('src/lib/services', () => {
describe('ServiceState', () => {
describe('constructor', () => {
it('initializes the instance with a payload', () => {
// Given
const serviceState = new ServicesLib.ServiceState({
data: {
spam: true,
},
error: {
message: 'FIAL',
},
});
// Then
expect(serviceState.payload.data).toEqual({spam: true});
expect(serviceState.payload.error).toEqual({message: 'FIAL'});
});
it('initializes the instance with the default payload', () => {
// Given
const serviceState = new ServicesLib.ServiceState();
// Then
expect(serviceState.payload.data).toBe(null);
expect(serviceState.payload.error).toBe(null);
});
});
describe('isLoading', () => {
it('returns true if both data and error fields are null', () => {
// Given
const serviceState = new ServicesLib.ServiceState();
// Then
expect(serviceState.isLoading()).toBe(true);
});
it('returns false if data is null and error is not null', () => {
// Given
const serviceState = new ServicesLib.ServiceState({
error: {
message: 'FIAL',
},
});
// Then
expect(serviceState.isLoading()).toBe(false);
});
it('returns false if data is not null and error is null', () => {
// Given
const serviceState = new ServicesLib.ServiceState({
data: {
spam: true,
},
});
// Then
expect(serviceState.isLoading()).toBe(false);
});
it('returns false if both data and error fields are not null', () => {
// Given
const serviceState = new ServicesLib.ServiceState({
data: {
spam: true,
},
error: {
message: 'FIAL',
},
});
// Then
expect(serviceState.isLoading()).toBe(false);
});
});
describe('hasData', () => {
it('returns false if isLoading is true and data is not null', () => {
// Given
const serviceState = new ServicesLib.ServiceState({
data: {
spam: true,
},
});
spyOn(serviceState, 'isLoading').and.returnValue(true);
// Then
expect(serviceState.hasData()).toBe(false);
});
it('returns false if isLoading is false and data is null', () => {
// Given
const serviceState = new ServicesLib.ServiceState();
spyOn(serviceState, 'isLoading').and.returnValue(true);
// Then
expect(serviceState.hasData()).toBe(false);
});
it('returns true if isLoading is false and data is not null', () => {
// Given
const serviceState = new ServicesLib.ServiceState({
data: {
spam: true,
},
});
spyOn(serviceState, 'isLoading').and.returnValue(false);
// Then
expect(serviceState.hasData()).toBe(true);
});
});
describe('hasError', () => {
it('returns false if isLoading is true and error is not null', () => {
// Given
const serviceState = new ServicesLib.ServiceState({
error: {
message: 'FIAL',
},
});
spyOn(serviceState, 'isLoading').and.returnValue(true);
// Then
expect(serviceState.hasError()).toBe(false);
});
it('returns false if isLoading is false and error is null', () => {
// Given
const serviceState = new ServicesLib.ServiceState();
spyOn(serviceState, 'isLoading').and.returnValue(true);
// Then
expect(serviceState.hasError()).toBe(false);
});
it('returns true if isLoading is false and error is not null', () => {
// Given
const serviceState = new ServicesLib.ServiceState({
error: {
message: 'FIAL',
},
});
spyOn(serviceState, 'isLoading').and.returnValue(false);
// Then
expect(serviceState.hasError()).toBe(true);
});
});
describe('hasFatalError', () => {
it('returns true if hasData is false and hasError is true', () => {
// Given
const serviceState = new ServicesLib.ServiceState();
spyOn(serviceState, 'hasData').and.returnValue(false);
spyOn(serviceState, 'hasError').and.returnValue(true);
// Then
expect(serviceState.hasFatalError()).toBe(true);
});
it('returns false if hasData is true and hasError is true', () => {
// Given
const serviceState = new ServicesLib.ServiceState();
spyOn(serviceState, 'hasData').and.returnValue(true);
spyOn(serviceState, 'hasError').and.returnValue(true);
// Then
expect(serviceState.hasFatalError()).toBe(false);
});
it('returns false if hasData is true and hasError is false', () => {
// Given
const serviceState = new ServicesLib.ServiceState();
spyOn(serviceState, 'hasData').and.returnValue(true);
spyOn(serviceState, 'hasError').and.returnValue(false);
// Then
expect(serviceState.hasFatalError()).toBe(false);
});
});
describe('update', () => {
it('returns a new ServiceState with updated payload', () => {
// Given
const serviceState = new ServicesLib.ServiceState();
// When
const result = serviceState.update({
data: {
spam: true,
},
error: {
message: 'FIAL',
},
});
// Then
expect(result).not.toBe(serviceState);
expect(result.payload.data).toEqual({spam: true});
expect(result.payload.error).toEqual({message: 'FIAL'});
expect(serviceState.payload).toEqual({
data: null,
error: null,
});
});
});
describe('data', () => {
it('returns the data payload field', () => {
// Given
const serviceState = new ServicesLib.ServiceState({
data: {
spam: true,
},
error: {
message: 'FIAL',
},
});
// Then
expect(serviceState.data()).toEqual(serviceState.payload.data);
});
});
describe('error', () => {
it('returns the error payload field', () => {
// Given
const serviceState = new ServicesLib.ServiceState({
data: {
spam: true,
},
error: {
message: 'FIAL',
},
});
// Then
expect(serviceState.error()).toEqual(serviceState.payload.error);
});
});
});
describe('BaseService', () => {
let spec = null;
beforeEach(() => {
spec = {
instance: 'fake_instance',
characteristics: {
spam: true,
},
widgetComponent: FakeWidget,
layout: {
x: 0,
y: 0,
w: 1,
h: 1,
},
};
});
it('includes the subscribable mixin', () => {
// Given
const service = new FakeService(spec);
// Then
expect(service.__mixins__).toContain('SubscribableMixin');
});
describe('emptyCharacteristics', () => {
it('returns the empty characteristics', () => {
// Given
const result = FakeService.emptyCharacteristics();
// Then
expect(result).toEqual({});
});
});
describe('constructor', () => {
it('initializes the instance', () => {
// Given
const service = new FakeService(spec);
// Then
expect(service.kind).toEqual(FakeService.kind);
expect(service.instance).toEqual(spec.instance);
expect(service.characteristics).toEqual(spec.characteristics);
expect(service.widget).toEqual(FakeService.widget);
expect(service.widgetComponent).toEqual(spec.widgetComponent);
expect(service.layout).toEqual(spec.layout);
});
});
describe('restart', () => {
it('restarts the service', async () => {
// Given
const service = new FakeService(spec);
spyOn(service, 'notify');
spyOn(service, 'start').and.resolveTo(null);
spyOn(service, 'stop').and.resolveTo(null);
// When
await service.restart();
// Then
expect(service.notify).toHaveBeenCalledWith(null, {reset: true});
expect(service.stop).toHaveBeenCalledBefore(service.start);
expect(service.start).toHaveBeenCalled();
});
});
describe('isDummy', () => {
it('returns false', () => {
// Given
const service = new FakeService(spec);
// Then
expect(service.isDummy()).toBe(false);
});
});
describe('initialState', () => {
it('returns null', () => {
// Given
const service = new FakeService(spec);
// Then
expect(service.initialState()).toBe(null);
});
});
describe('setCharacteristics', () => {
it('sets the new characteristics', () => {
// Given
const service = new FakeService(spec);
const newCharacteristics = {spam: false};
// When
service.setCharacteristics(newCharacteristics);
// Then
expect(service.characteristics).not.toBe(newCharacteristics);
expect(service.characteristics).toEqual(newCharacteristics);
});
});
describe('setLayout', () => {
it('sets the new layout', () => {
// Given
const service = new FakeService(spec);
const newLayout = {x: 1, y: 1, w: 2, h: 2};
// When
service.setLayout(newLayout);
// Then
expect(service.layout).not.toBe(newLayout);
expect(service.layout).toEqual(newLayout);
});
});
describe('toJSON', () => {
it('returns a JSON-serializable representation of the service', () => {
// Given
const service = new FakeService(spec);
// When
const result = service.toJSON();
// Then
expect(result).toEqual({
kind: service.kind,
instance: service.instance,
characteristics: service.characteristics,
layout: service.layout,
});
});
});
});
describe('DummyService', () => {
let spec = null;
beforeEach(() => {
spec = {
instance: 'fake_instance',
characteristics: {
spam: true,
},
widgetComponent: FakeWidget,
layout: {
x: 0,
y: 0,
w: 1,
h: 1,
},
};
});
describe('constructor', () => {
it('initializes the instance', () => {
// Given
const service = new ServicesLib.DummyService(spec);
// Then
expect(service.instance).toBe(null);
expect(service.widgetComponent).toBe(null);
});
});
describe('isDummy', () => {
it('returns true', () => {
// Given
const service = new ServicesLib.DummyService(spec);
// Then
expect(service.isDummy()).toBe(true);
});
});
});
describe('lookupService', () => {
let dashboards = null;
beforeEach(() => {
dashboards = DashboardsFactory();
});
it('returns DummyService instance if the specified kind does not exist', () => {
// Given
const result = ServicesLib.lookupService(
dashboards, 'Testing', 'fake_instance'
);
// Then
expect(result).toBeInstanceOf(ServicesLib.DummyService);
expect(result.isDummy()).toBe(true);
});
it('returns DummyService instance if the specified instance does not exist', () => {
// Given
const result = ServicesLib.lookupService(
dashboards, 'FakeService', 'testing'
);
// Then
expect(result).toBeInstanceOf(ServicesLib.DummyService);
expect(result.isDummy()).toBe(true);
});
it('returns the service that matches the specified kind and instace', () => {
// Given
const result = ServicesLib.lookupService(
dashboards, 'FakeService', 'fake_instance'
);
// Then
expect(result).toBe(dashboards[0].services[0]);
});
});
describe('lookupServices', () => {
let dashboards = null;
beforeEach(() => {
dashboards = DashboardsFactory();
dashboards[0].services.push(new ServicesLib.DummyService());
dashboards[0].services.push(new FakeService({
instance: 'fake_instance2',
}));
});
it('returns all instances of services specified by kind', () => {
// Givem
const result = ServicesLib.lookupServices(dashboards, 'FakeService');
// Then
expect(result.length).toEqual(3);
expect(result[0]).toBe(dashboards[0].services[0]);
expect(result[1]).toBe(dashboards[0].services[1]);
expect(result[2]).toBe(dashboards[0].services[3]);
});
});
});

View File

@@ -0,0 +1,268 @@
import * as WebSocketLib from 'src/lib/websocket';
describe('src/lib/websocket', () => {
describe('HomeHubWebSocket', () => {
const settings = {
url: '/websocket',
};
it('includes the subscribable mixin', () => {
// Given
const webSocket = new WebSocketLib.HomeHubWebSocket(false, settings);
// Then
expect(webSocket.__mixins__).toContain('SubscribableMixin');
});
it('includes the event source mixin', () => {
// Given
const webSocket = new WebSocketLib.HomeHubWebSocket(false, settings);
// Then
expect(webSocket.__mixins__).toContain('EventSourceMixin');
});
describe('constructor', () => {
it('initializes the instance', () => {
// Given
const webSocket = new WebSocketLib.HomeHubWebSocket(false, settings);
// Then
expect(webSocket.debug).toBe(false);
expect(webSocket.settings).toEqual(settings);
expect(webSocket.socket).toBe(null);
expect(webSocket.reconnectTimeout).toBe(null);
expect(webSocket.reconnectCounter).toEqual(0);
});
});
describe('logDebug', () => {
beforeEach(() => {
spyOn(console, 'log');
});
it('logs a message if debug is true', () => {
// Given
const webSocket = new WebSocketLib.HomeHubWebSocket(true, settings);
// When
webSocket.logDebug('Testing');
// Then
expect(console.log).toHaveBeenCalledWith('Testing'); // eslint-disable-line no-console
});
it('does not log a message if debug is false', () => {
// Given
const webSocket = new WebSocketLib.HomeHubWebSocket(false, settings);
// When
webSocket.logDebug('Testing');
// Then
expect(console.log).not.toHaveBeenCalled(); // eslint-disable-line no-console
});
});
describe('stopReconnect', () => {
beforeEach(() => {
spyOn(window, 'clearTimeout');
});
it('stops the reconnect process', () => {
// Given
const webSocket = new WebSocketLib.HomeHubWebSocket(false, settings);
webSocket.reconnectTimeout = 123;
webSocket.reconnectCounter = 5;
// When
webSocket.stopReconnect();
// Then
expect(webSocket.reconnectTimeout).toBe(null);
expect(webSocket.reconnectCounter).toEqual(0);
expect(window.clearTimeout).toHaveBeenCalledWith(123);
});
});
describe('startReconnect', () => {
beforeEach(() => {
spyOn(window, 'setTimeout').and.returnValue(123);
});
it('starts the reconnect process', () => {
// Given
const webSocket = new WebSocketLib.HomeHubWebSocket(false, settings);
webSocket.reconnectCounter = 5;
// When
webSocket.startReconnect();
// Then
expect(webSocket.reconnectCounter).toEqual(6);
expect(webSocket.reconnectTimeout).toEqual(123);
expect(window.setTimeout).toHaveBeenCalledWith(webSocket.start, 1000);
});
it('breaks the reconnect process when retry count reaches limit', () => {
// Given
const webSocket = new WebSocketLib.HomeHubWebSocket(false, settings);
webSocket.reconnectCounter = 30;
spyOn(webSocket, 'stopReconnect');
// When
let error = null;
try {
webSocket.startReconnect();
} catch (exc) {
error = exc;
}
// Then
expect(error).toBeInstanceOf(Error);
expect(webSocket.stopReconnect).toHaveBeenCalled();
});
});
describe('start', () => {
let fakeWebSocket = null;
beforeEach(() => {
fakeWebSocket = jasmine.createSpyObj(['addEventListener', 'close']);
spyOn(window, 'WebSocket').and.returnValue(fakeWebSocket);
});
it('configures and opens the websocket connection', () => {
// Given
let fullSettings = {
...settings,
protocols: ['spam', 'eggs'],
};
const webSocket = new WebSocketLib.HomeHubWebSocket(
false, fullSettings
);
// When
webSocket.start();
// Then
expect(window.WebSocket).toHaveBeenCalledWith(
fullSettings.url, fullSettings.protocols
);
expect(fakeWebSocket.addEventListener).toHaveBeenCalledWith(
'open', webSocket.onSocketOpen
);
expect(fakeWebSocket.addEventListener).toHaveBeenCalledWith(
'close', webSocket.onSocketClose
);
expect(fakeWebSocket.addEventListener).toHaveBeenCalledWith(
'message', webSocket.onSocketMessage
);
});
});
describe('stop', () => {
let fakeWebSocket = null;
beforeEach(() => {
fakeWebSocket = jasmine.createSpyObj(['addEventListener', 'close']);
});
it('closes the websocket connection', () => {
// Given
const webSocket = new WebSocketLib.HomeHubWebSocket(false, settings);
webSocket.socket = fakeWebSocket;
// When
webSocket.stop();
// Then
expect(fakeWebSocket.close).toHaveBeenCalled();
});
});
describe('onSocketOpen', () => {
let fakeWebSocket = null;
beforeEach(() => {
fakeWebSocket = jasmine.createSpyObj(['addEventListener', 'close']);
fakeWebSocket.readyState = 1;
});
it('handles the open websocket event', () => {
// Given
const webSocket = new WebSocketLib.HomeHubWebSocket(false, settings);
webSocket.socket = fakeWebSocket;
spyOn(webSocket, 'logDebug');
spyOn(webSocket, 'stopReconnect');
spyOn(webSocket, 'fireEvent');
// When
webSocket.onSocketOpen();
// Then
expect(webSocket.logDebug).toHaveBeenCalled();
expect(webSocket.stopReconnect).toHaveBeenCalled();
expect(webSocket.fireEvent).toHaveBeenCalledWith('start');
});
});
describe('onSocketClose', () => {
it('handles the open websocket event', () => {
// Given
const webSocket = new WebSocketLib.HomeHubWebSocket(false, settings);
spyOn(webSocket, 'logDebug');
spyOn(webSocket, 'startReconnect');
spyOn(webSocket, 'fireEvent');
// When
webSocket.onSocketClose({code: 1000});
// Then
expect(webSocket.logDebug).toHaveBeenCalled();
expect(webSocket.startReconnect).toHaveBeenCalled();
expect(webSocket.fireEvent).toHaveBeenCalledWith('stop');
});
});
describe('onSocketMessage', () => {
beforeEach(() => {
spyOn(console, 'error');
});
it('gracefully handles JSON parse error', () => {
// Given
const webSocket = new WebSocketLib.HomeHubWebSocket(false, settings);
spyOn(webSocket, 'notify');
// When
webSocket.onSocketMessage({data: 'spam'});
// Then
expect(console.error).toHaveBeenCalledWith(
jasmine.any(String), jasmine.any(Error)
);
expect(webSocket.notify).not.toHaveBeenCalled();
});
it('parses the event data and notifies the subscribers', () => {
// Given
const webSocket = new WebSocketLib.HomeHubWebSocket(false, settings);
spyOn(webSocket, 'notify');
const message = {
type: 'TESTING',
data: {
spam: true,
},
};
// When
webSocket.onSocketMessage({data: JSON.stringify(message)});
// Then
expect(webSocket.notify).toHaveBeenCalledWith(message);
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,107 @@
const path = require('path');
const {CleanWebpackPlugin} = require('clean-webpack-plugin');
const webpack = require('webpack');
const CONTEXT = path.resolve(__dirname);
const SCSS_DIR = path.resolve(CONTEXT, 'scss');
const PROJECT_DIR = path.resolve(CONTEXT, 'src');
const OUTPUT_DIR = path.resolve(CONTEXT, 'lib');
const IS_PRODUCTION = (process.env['NODE_ENV'] === 'production');
const config = {
mode: 'development',
devtool: 'source-map',
context: CONTEXT,
entry: {
index: './src/index.js',
},
output: {
path: OUTPUT_DIR,
filename: '[name].js',
library: 'homehub-core',
libraryTarget: 'umd',
umdNamedDefine: true,
},
module: {
rules: [
{
test: /\.js?$/,
include: PROJECT_DIR,
use: [
'babel-loader',
],
},
{
test: /\.scss?$/,
include: PROJECT_DIR,
use: [
'style-loader',
'css-loader',
'sass-loader',
],
},
{
test: /\.css?$/,
use: [
'style-loader',
'css-loader',
],
},
{
test: /\.(svg)$/,
include: PROJECT_DIR,
use: [
{
loader: 'babel-loader',
},
{
loader: 'react-svg-loader',
options: {
jsx: true,
},
},
],
},
{
test: /\.(png)$/,
include: PROJECT_DIR,
use: [
{
loader: 'file-loader',
options: {
name: '[name].[ext]',
},
},
],
},
{
test: /\.scss$/,
include: SCSS_DIR,
use: [
'null-loader',
],
},
],
},
resolve: {
alias: {
'scss': SCSS_DIR,
'src': PROJECT_DIR,
},
},
externals: {
'prop-types': 'prop-types',
react: 'react',
'react-dom': 'react-dom',
},
plugins: [
new CleanWebpackPlugin(),
new webpack.DefinePlugin({
PRODUCTION: IS_PRODUCTION,
}),
],
};
module.exports = config;

File diff suppressed because it is too large Load Diff