@iobroker/testing
Version:
Shared utilities for adapter and module testing in ioBroker
286 lines (285 loc) • 11.6 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TestHarness = void 0;
const async_1 = require("alcalzone-shared/async");
const objects_1 = require("alcalzone-shared/objects");
const child_process_1 = require("child_process");
const debug_1 = __importDefault(require("debug"));
const events_1 = require("events");
const path = __importStar(require("path"));
const adapterTools_1 = require("../../../lib/adapterTools");
const tools_1 = require("./tools");
const debug = (0, debug_1.default)('testing:integration:TestHarness');
const isWindows = /^win/.test(process.platform);
const fromAdapterID = 'system.adapter.test.0';
/**
* The test harness capsules the execution of the JS-Controller and the adapter instance and monitors their status.
* Use it in every test to start a fresh adapter instance
*/
class TestHarness extends events_1.EventEmitter {
/**
* @param adapterDir The root directory of the adapter
* @param testDir The directory the integration tests are executed in
*/
constructor(adapterDir, testDir, dbConnection) {
super();
this.adapterDir = adapterDir;
this.testDir = testDir;
this.dbConnection = dbConnection;
this.sendToID = 1;
debug('Creating instance');
this.adapterName = (0, adapterTools_1.getAdapterName)(this.adapterDir);
this.appName = (0, adapterTools_1.getAppName)(adapterDir);
this.testControllerDir = (0, tools_1.getTestControllerDir)(this.appName, testDir);
this.testAdapterDir = (0, tools_1.getTestAdapterDir)(this.adapterDir, testDir);
debug(` directories:`);
debug(` controller: ${this.testControllerDir}`);
debug(` adapter: ${this.testAdapterDir}`);
debug(` appName: ${this.appName}`);
debug(` adapterName: ${this.adapterName}`);
dbConnection.on('objectChange', (id, obj) => {
this.emit('objectChange', id, obj);
});
dbConnection.on('stateChange', (id, state) => {
this.emit('stateChange', id, state);
});
}
/** Gives direct access to the Objects DB */
get objects() {
if (!this.dbConnection.objectsClient) {
throw new Error('Objects DB is not running');
}
return this.dbConnection.objectsClient;
}
/** Gives direct access to the States DB */
get states() {
if (!this.dbConnection.statesClient) {
throw new Error('States DB is not running');
}
return this.dbConnection.statesClient;
}
/** The process the adapter is running in */
get adapterProcess() {
return this._adapterProcess;
}
/** Contains the adapter exit code or signal if it was terminated unexpectedly */
get adapterExit() {
return this._adapterExit;
}
/** Checks if the controller instance is running */
isControllerRunning() {
// The "controller instance" is just the databases, so if they are running,
// the "controller" is.
return this.dbConnection.isRunning;
}
/** Starts the controller instance by creating the databases */
async startController() {
await this.dbConnection.start();
}
/** Stops the controller instance (and the adapter if it is running) */
async stopController() {
if (!this.isControllerRunning()) {
return;
}
if (!this.didAdapterStop()) {
debug('Stopping adapter instance...');
// Give the adapter time to stop (as long as configured in the io-package.json)
let stopTimeout;
try {
stopTimeout = (await this.dbConnection.getObject(`system.adapter.${this.adapterName}.0`))
.common.stopTimeout;
stopTimeout += 1000;
}
catch {
// ignore
}
stopTimeout ||= 5000; // default 5s
debug(` => giving it ${stopTimeout}ms to terminate`);
await Promise.race([this.stopAdapter(), (0, async_1.wait)(stopTimeout)]);
if (this.isAdapterRunning()) {
debug('Adapter did not terminate, killing it');
this._adapterProcess.kill('SIGKILL');
}
else {
debug('Adapter terminated');
}
}
else {
debug('Adapter failed to start - no need to terminate!');
}
await this.dbConnection.stop();
}
/**
* Starts the adapter in a separate process and monitors its status
*
* @param env Additional environment variables to set
*/
async startAdapter(env = {}) {
if (this.isAdapterRunning()) {
throw new Error('The adapter is already running!');
}
else if (this.didAdapterStop()) {
throw new Error('This test harness has already been used. Please create a new one for each test!');
}
const mainFileAbsolute = await (0, adapterTools_1.locateAdapterMainFile)(this.testAdapterDir);
const mainFileRelative = path.relative(this.testAdapterDir, mainFileAbsolute);
const onClose = (code, signal) => {
this._adapterProcess.removeAllListeners();
this._adapterExit = code != undefined ? code : signal;
this.emit('failed', this._adapterExit);
};
this._adapterProcess = (0, child_process_1.spawn)(isWindows ? 'node.exe' : 'node', [mainFileRelative, '--console'], {
cwd: this.testAdapterDir,
stdio: ['inherit', 'inherit', 'inherit'],
env: { ...process.env, ...env },
})
.on('close', onClose)
.on('exit', onClose);
}
/**
* Starts the adapter in a separate process and resolves after it has started
*
* @param waitForConnection By default, the test will wait for the adapter's `alive` state to become true. Set this to `true` to wait for the `info.connection` state instead.
* @param env Additional environment variables to set
*/
async startAdapterAndWait(waitForConnection = false, env = {}) {
return new Promise((resolve, reject) => {
const waitForStateId = waitForConnection
? `${this.adapterName}.0.info.connection`
: `system.adapter.${this.adapterName}.0.alive`;
void this.on('stateChange', (id, state) => {
if (id === waitForStateId && state && state.val === true) {
resolve();
}
})
.on('failed', code => {
reject(new Error(`The adapter startup was interrupted unexpectedly with ${typeof code === 'number' ? 'code' : 'signal'} ${code}`));
})
.startAdapter(env);
});
}
/** Tests if the adapter process is still running */
isAdapterRunning() {
return !!this._adapterProcess;
}
/** Tests if the adapter process has already exited */
didAdapterStop() {
return this._adapterExit != undefined;
}
/** Stops the adapter process */
stopAdapter() {
if (!this.isAdapterRunning()) {
return;
}
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve) => {
const onClose = (code, signal) => {
if (!this._adapterProcess) {
return;
}
this._adapterProcess.removeAllListeners();
this._adapterExit = code != undefined ? code : signal;
this._adapterProcess = undefined;
debug('Adapter process terminated:');
debug(` Code: ${code}`);
debug(` Signal: ${signal}`);
resolve();
};
this._adapterProcess.removeAllListeners().on('close', onClose).on('exit', onClose);
// Tell adapter to stop
try {
await this.dbConnection.setState(`system.adapter.${this.adapterName}.0.sigKill`, {
val: -1,
from: 'system.host.testing',
});
}
catch {
// DB connection may be closed already, kill the process
this._adapterProcess?.kill('SIGTERM');
}
});
}
/**
* Updates the adapter config. The changes can be a subset of the target object
*/
async changeAdapterConfig(adapterName, changes) {
const adapterInstanceId = `system.adapter.${adapterName}.0`;
const obj = await this.dbConnection.getObject(adapterInstanceId);
if (obj) {
(0, objects_1.extend)(obj, changes);
await this.dbConnection.setObject(adapterInstanceId, obj);
}
}
getAdapterExecutionMode() {
return (0, adapterTools_1.getAdapterExecutionMode)(this.testAdapterDir);
}
/** Enables the sendTo method */
async enableSendTo() {
await this.dbConnection.setObject(fromAdapterID, {
type: 'instance',
common: {},
native: {},
instanceObjects: [],
objects: [],
});
this.dbConnection.subscribeMessage(fromAdapterID);
}
/** Sends a message to an adapter instance */
sendTo(target, command, message, callback) {
const stateChangedHandler = (id, state) => {
if (id === `messagebox.${fromAdapterID}`) {
callback(state.message);
this.removeListener('stateChange', stateChangedHandler);
}
};
this.addListener('stateChange', stateChangedHandler);
this.dbConnection.pushMessage(`system.adapter.${target}`, {
command: command,
message: message,
from: fromAdapterID,
callback: {
message: message,
id: this.sendToID++,
ack: false,
time: Date.now(),
},
}, (err, id) => console.log(`published message ${id}`));
}
}
exports.TestHarness = TestHarness;