@iobroker/testing 6.0.0 → 6.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -3
- package/build/tests/index.d.ts +1 -1
- package/build/tests/integration/lib/controllerSetup.d.ts +18 -0
- package/build/tests/integration/lib/controllerSetup.js +55 -0
- package/build/tests/integration/lib/harness.d.ts +84 -1
- package/build/tests/integration/lib/harness.js +193 -3
- package/build/tests/packageFiles/index.d.ts +3 -1
- package/build/tests/packageFiles/index.js +128 -1
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -75,6 +75,20 @@ tests.integration(path.join(__dirname, ".."), {
|
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
// Checking the adapter log
|
|
79
|
+
suite("Check the log", (getHarness) => {
|
|
80
|
+
let harness;
|
|
81
|
+
before(() => {
|
|
82
|
+
harness = getHarness();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("Should not log the follow-up error", async () => {
|
|
86
|
+
await harness.startAdapterAndWait();
|
|
87
|
+
|
|
88
|
+
expect(harness.hasLog(/follow-up-bug detected/, "error")).to.be.false;
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
78
92
|
// While developing the tests, you can run only a single suite using `suite.only`...
|
|
79
93
|
suite.only("Only this will run", (getHarness) => {
|
|
80
94
|
// ...
|
|
@@ -87,14 +101,51 @@ tests.integration(path.join(__dirname, ".."), {
|
|
|
87
101
|
});
|
|
88
102
|
```
|
|
89
103
|
|
|
104
|
+
### Checking the adapter log (Integration test)
|
|
105
|
+
|
|
106
|
+
The test harness captures everything the adapter under test prints while it is running, so tests can check
|
|
107
|
+
that a specific message was (or was not) logged:
|
|
108
|
+
|
|
109
|
+
- `getLogs(level?)` returns the captured log messages, optionally filtered by log level. Each entry looks like this:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
interface AdapterLog {
|
|
113
|
+
/** The log level the message was logged with */
|
|
114
|
+
level: "silly" | "debug" | "info" | "warn" | "error";
|
|
115
|
+
/** The time the message was logged at */
|
|
116
|
+
timestamp: Date;
|
|
117
|
+
/** The source of the message, e.g. `my-adapter.0`. Undefined if it could not be determined */
|
|
118
|
+
from: string | undefined;
|
|
119
|
+
/** The logged message without timestamp, level and source */
|
|
120
|
+
message: string;
|
|
121
|
+
/** The unparsed log line as it was printed by the adapter */
|
|
122
|
+
raw: string;
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- `hasLog(pattern, level?)` returns whether a message matching `pattern` was logged. `pattern` is either a `RegExp`
|
|
127
|
+
or a string that must be contained in the message. If `level` is given, only messages with that log level are checked.
|
|
128
|
+
- `clearLogs()` forgets all messages that were captured so far, e.g. to check only the messages of the next step.
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
await harness.startAdapterAndWait();
|
|
132
|
+
|
|
133
|
+
expect(harness.hasLog(/connection failed/, "error")).to.be.false;
|
|
134
|
+
expect(harness.getLogs("error")).to.be.empty;
|
|
135
|
+
|
|
136
|
+
harness.clearLogs();
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Lines that are not in the ioBroker log format - e.g. output of a plain `console.log()` - are captured as `info` messages.
|
|
140
|
+
|
|
90
141
|
### Adapter startup (Unit test)
|
|
91
142
|
|
|
92
143
|
**Unit tests for adapter startup were removed and are essentially a no-op now.**
|
|
93
144
|
If you defined your own tests, they should still work.
|
|
94
145
|
|
|
95
146
|
```ts
|
|
96
|
-
const path = require(
|
|
97
|
-
const { tests } = require(
|
|
147
|
+
const path = require('node:path');
|
|
148
|
+
const { tests } = require('@iobroker/testing');
|
|
98
149
|
|
|
99
150
|
tests.unit(path.join(__dirname, ".."), {
|
|
100
151
|
// ~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
@@ -103,7 +154,7 @@ tests.unit(path.join(__dirname, ".."), {
|
|
|
103
154
|
// Define your own tests inside defineAdditionalTests.
|
|
104
155
|
// If you need predefined objects etc. here, you need to take care of it yourself
|
|
105
156
|
defineAdditionalTests() {
|
|
106
|
-
it(
|
|
157
|
+
it('works', () => {
|
|
107
158
|
// see below how these could look like
|
|
108
159
|
});
|
|
109
160
|
},
|
package/build/tests/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { validatePackageFiles } from './packageFiles';
|
|
|
3
3
|
import { testAdapterWithMocks } from './unit';
|
|
4
4
|
import { createMocks } from './unit/harness/createMocks';
|
|
5
5
|
import { createAsserts } from './unit/mocks/mockDatabase';
|
|
6
|
-
export { TestHarness as IntegrationTestHarness } from './integration/lib/harness';
|
|
6
|
+
export { TestHarness as IntegrationTestHarness, type AdapterLog } from './integration/lib/harness';
|
|
7
7
|
export type { MockAdapter } from './unit/mocks/mockAdapter';
|
|
8
8
|
export { MockDatabase } from './unit/mocks/mockDatabase';
|
|
9
9
|
/** Predefined test sets */
|
|
@@ -8,6 +8,24 @@ export declare class ControllerSetup {
|
|
|
8
8
|
private testAdapterDir;
|
|
9
9
|
private testControllerDir;
|
|
10
10
|
private testDataDir;
|
|
11
|
+
/**
|
|
12
|
+
* Returns the path of the file that stores which JS-Controller version is installed in the test directory
|
|
13
|
+
*/
|
|
14
|
+
private getControllerVersionFilePath;
|
|
15
|
+
/**
|
|
16
|
+
* Reads which JS-Controller version was installed in the test directory during the previous run.
|
|
17
|
+
* Returns `null` if this is unknown.
|
|
18
|
+
*/
|
|
19
|
+
private getInstalledControllerVersion;
|
|
20
|
+
/**
|
|
21
|
+
* Remembers which JS-Controller version is installed in the test directory
|
|
22
|
+
*/
|
|
23
|
+
private saveInstalledControllerVersion;
|
|
24
|
+
/**
|
|
25
|
+
* Removes the installed dependencies and the data directory from the test directory.
|
|
26
|
+
* This is necessary when switching JS-Controller versions, so no stale files and states are left behind.
|
|
27
|
+
*/
|
|
28
|
+
private clearTestDir;
|
|
11
29
|
prepareTestDir(controllerVersion?: string): Promise<void>;
|
|
12
30
|
/**
|
|
13
31
|
* Tests if JS-Controller is already installed
|
|
@@ -70,6 +70,52 @@ class ControllerSetup {
|
|
|
70
70
|
testAdapterDir;
|
|
71
71
|
testControllerDir;
|
|
72
72
|
testDataDir;
|
|
73
|
+
/**
|
|
74
|
+
* Returns the path of the file that stores which JS-Controller version is installed in the test directory
|
|
75
|
+
*/
|
|
76
|
+
getControllerVersionFilePath() {
|
|
77
|
+
return path.join(this.testDir, '.controller-version');
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Reads which JS-Controller version was installed in the test directory during the previous run.
|
|
81
|
+
* Returns `null` if this is unknown.
|
|
82
|
+
*/
|
|
83
|
+
async getInstalledControllerVersion() {
|
|
84
|
+
const versionFilePath = this.getControllerVersionFilePath();
|
|
85
|
+
try {
|
|
86
|
+
if (!(await (0, fs_extra_1.pathExists)(versionFilePath))) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const version = await (0, fs_extra_1.readFile)(versionFilePath, 'utf8');
|
|
90
|
+
return version.trim() || null;
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
debug(`Could not read the installed JS-Controller version: ${e}`);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Remembers which JS-Controller version is installed in the test directory
|
|
99
|
+
*/
|
|
100
|
+
async saveInstalledControllerVersion(controllerVersion) {
|
|
101
|
+
try {
|
|
102
|
+
await (0, fs_extra_1.writeFile)(this.getControllerVersionFilePath(), controllerVersion, 'utf8');
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
debug(`Could not save the installed JS-Controller version: ${e}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Removes the installed dependencies and the data directory from the test directory.
|
|
110
|
+
* This is necessary when switching JS-Controller versions, so no stale files and states are left behind.
|
|
111
|
+
*/
|
|
112
|
+
async clearTestDir() {
|
|
113
|
+
debug('Clearing the test directory...');
|
|
114
|
+
await (0, fs_extra_1.emptyDir)(path.join(this.testDir, 'node_modules'));
|
|
115
|
+
await (0, fs_extra_1.emptyDir)(this.testDataDir);
|
|
116
|
+
await this.clearLogDir();
|
|
117
|
+
debug(' => done!');
|
|
118
|
+
}
|
|
73
119
|
async prepareTestDir(controllerVersion) {
|
|
74
120
|
const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10);
|
|
75
121
|
// js-controller 7.2.3 dropped support for Node.js 18 and 20. If no specific
|
|
@@ -81,6 +127,13 @@ class ControllerSetup {
|
|
|
81
127
|
debug(`Preparing the test directory. JS-Controller version: "${controllerVersion}"...`);
|
|
82
128
|
// Make sure the test dir exists
|
|
83
129
|
await (0, fs_extra_1.ensureDir)(this.testDir);
|
|
130
|
+
// If the test directory was previously used with a different JS-Controller version,
|
|
131
|
+
// remove the installed files and the data directory, so no stale state is left behind
|
|
132
|
+
const installedControllerVersion = await this.getInstalledControllerVersion();
|
|
133
|
+
if (installedControllerVersion && installedControllerVersion !== controllerVersion) {
|
|
134
|
+
debug(`JS-Controller version changed from "${installedControllerVersion}" to "${controllerVersion}", cleaning up...`);
|
|
135
|
+
await this.clearTestDir();
|
|
136
|
+
}
|
|
84
137
|
// Write the package.json
|
|
85
138
|
const packageJson = {
|
|
86
139
|
name: path.basename(this.testDir),
|
|
@@ -123,6 +176,8 @@ class ControllerSetup {
|
|
|
123
176
|
if (wasJsControllerInstalled) {
|
|
124
177
|
await this.setupJsController();
|
|
125
178
|
}
|
|
179
|
+
// Remember which version is installed now, so we can detect a version change on the next run
|
|
180
|
+
await this.saveInstalledControllerVersion(controllerVersion);
|
|
126
181
|
debug(' => done!');
|
|
127
182
|
}
|
|
128
183
|
/**
|
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
import { type ChildProcess } from 'node:child_process';
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
3
|
import type { DBConnection } from './dbConnection';
|
|
4
|
+
/** A single log message of the adapter under test */
|
|
5
|
+
export interface AdapterLog {
|
|
6
|
+
/** The log level the message was logged with */
|
|
7
|
+
level: ioBroker.LogLevel;
|
|
8
|
+
/** The time the message was logged at */
|
|
9
|
+
timestamp: Date;
|
|
10
|
+
/** The source of the message, e.g. `my-adapter.0`. Undefined if it could not be determined */
|
|
11
|
+
from: string | undefined;
|
|
12
|
+
/** The logged message without timestamp, level and source */
|
|
13
|
+
message: string;
|
|
14
|
+
/** The unparsed log line as it was printed by the adapter */
|
|
15
|
+
raw: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Parses a line of the adapter output into a structured log message.
|
|
19
|
+
* Lines that are not in the ioBroker log format (e.g. plain `console.log` output)
|
|
20
|
+
* are returned as `info` messages.
|
|
21
|
+
*
|
|
22
|
+
* @param line A single line of the adapter's stdout/stderr
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseAdapterLogLine(line: string): AdapterLog;
|
|
4
25
|
export interface TestHarness {
|
|
5
26
|
on(event: 'objectChange', handler: ioBroker.ObjectChangeHandler): this;
|
|
6
27
|
on(event: 'stateChange', handler: ioBroker.StateChangeHandler): this;
|
|
@@ -59,13 +80,75 @@ export declare class TestHarness extends EventEmitter {
|
|
|
59
80
|
/** Stops the adapter process */
|
|
60
81
|
stopAdapter(): Promise<void> | undefined;
|
|
61
82
|
/**
|
|
62
|
-
* Updates the adapter config. The changes can be a subset of the target object
|
|
83
|
+
* Updates the adapter config. The changes can be a subset of the target object.
|
|
84
|
+
* The `native` properties that are listed in the instance object's `encryptedNative`
|
|
85
|
+
* are encrypted automatically, so they can be passed in plain text.
|
|
63
86
|
*/
|
|
64
87
|
changeAdapterConfig(adapterName: string, changes: Record<string, any>): Promise<void>;
|
|
88
|
+
/**
|
|
89
|
+
* Reads the config of an adapter instance. The `native` properties that are listed in the
|
|
90
|
+
* instance object's `encryptedNative` are decrypted automatically, so they are returned in plain text.
|
|
91
|
+
*
|
|
92
|
+
* @param adapterName The name of the adapter. Defaults to the adapter under test.
|
|
93
|
+
*/
|
|
94
|
+
getAdapterConfig(adapterName?: string): Promise<ioBroker.InstanceObject | null>;
|
|
95
|
+
/**
|
|
96
|
+
* Returns the names of all `native` properties in the given config that must be en-/decrypted
|
|
97
|
+
*/
|
|
98
|
+
private getEncryptedFields;
|
|
99
|
+
/**
|
|
100
|
+
* Encrypts all `native` properties of the given changes that are listed in the instance
|
|
101
|
+
* object's `encryptedNative`. Returns the changes to apply - the passed object is not modified.
|
|
102
|
+
*/
|
|
103
|
+
private encryptNativeChanges;
|
|
104
|
+
private _systemSecret;
|
|
105
|
+
/**
|
|
106
|
+
* Reads the secret from the `system.config` object. The secret is cached after the first read.
|
|
107
|
+
*/
|
|
108
|
+
private getSystemSecret;
|
|
109
|
+
/**
|
|
110
|
+
* Encrypts a value the same way the JS-Controller does for `encryptedNative` properties
|
|
111
|
+
*/
|
|
112
|
+
encryptValue(value: string): Promise<string>;
|
|
113
|
+
/**
|
|
114
|
+
* Decrypts a value that was encrypted for an `encryptedNative` property
|
|
115
|
+
*/
|
|
116
|
+
decryptValue(value: string): Promise<string>;
|
|
65
117
|
getAdapterExecutionMode(): ioBroker.AdapterCommon['mode'];
|
|
66
118
|
/** Enables the sendTo method */
|
|
67
119
|
enableSendTo(): Promise<void>;
|
|
68
120
|
private sendToID;
|
|
69
121
|
/** Sends a message to an adapter instance */
|
|
70
122
|
sendTo(target: string, command: string, message: any, callback: ioBroker.MessageCallback): void;
|
|
123
|
+
/** The log messages of the adapter under test */
|
|
124
|
+
private _logs;
|
|
125
|
+
/** The incomplete last line of each output stream, waiting for the rest to arrive */
|
|
126
|
+
private _outputBuffer;
|
|
127
|
+
/**
|
|
128
|
+
* Handles a chunk of the adapter's output. Because a chunk may end in the middle of a line,
|
|
129
|
+
* the incomplete rest is buffered until the remainder arrives.
|
|
130
|
+
*
|
|
131
|
+
* @param chunk The received chunk
|
|
132
|
+
* @param stream Which of the adapter's output streams the chunk was received on
|
|
133
|
+
*/
|
|
134
|
+
private handleAdapterOutput;
|
|
135
|
+
/** Prints a line of the adapter's output and remembers it as a log message */
|
|
136
|
+
private handleAdapterOutputLine;
|
|
137
|
+
/** Handles the incomplete lines that were left over when the adapter exited */
|
|
138
|
+
private flushAdapterOutput;
|
|
139
|
+
/**
|
|
140
|
+
* Returns the log messages the adapter has printed so far
|
|
141
|
+
*
|
|
142
|
+
* @param level If given, only the messages with this log level are returned
|
|
143
|
+
*/
|
|
144
|
+
getLogs(level?: ioBroker.LogLevel): AdapterLog[];
|
|
145
|
+
/** Forgets all log messages that were captured so far */
|
|
146
|
+
clearLogs(): void;
|
|
147
|
+
/**
|
|
148
|
+
* Tests if the adapter has logged a message matching the given pattern
|
|
149
|
+
*
|
|
150
|
+
* @param pattern A RegExp or a string that must be contained in the message
|
|
151
|
+
* @param level If given, only the messages with this log level are checked
|
|
152
|
+
*/
|
|
153
|
+
hasLog(pattern: string | RegExp, level?: ioBroker.LogLevel): boolean;
|
|
71
154
|
}
|
|
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.TestHarness = void 0;
|
|
40
|
+
exports.parseAdapterLogLine = parseAdapterLogLine;
|
|
40
41
|
const async_1 = require("alcalzone-shared/async");
|
|
41
42
|
const objects_1 = require("alcalzone-shared/objects");
|
|
42
43
|
const node_child_process_1 = require("node:child_process");
|
|
@@ -47,7 +48,54 @@ const adapterTools_1 = require("../../../lib/adapterTools");
|
|
|
47
48
|
const tools_1 = require("./tools");
|
|
48
49
|
const debug = (0, debug_1.default)('testing:integration:TestHarness');
|
|
49
50
|
const isWindows = /^win/.test(process.platform);
|
|
51
|
+
const logLevels = ['silly', 'debug', 'info', 'warn', 'error'];
|
|
52
|
+
/** Matches the color codes the adapter logger adds to the console output */
|
|
53
|
+
// eslint-disable-next-line no-control-regex
|
|
54
|
+
const ansiRegex = /\x1B\[\d+m/g;
|
|
55
|
+
/** Matches `2023-11-08 13:31:57.123 - info: my-adapter.0 (1234) The message` */
|
|
56
|
+
const logLineRegex = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\s+-\s+(\w+):\s+([\s\S]*)$/;
|
|
57
|
+
/** Matches the `my-adapter.0 (1234) ` prefix the adapter logger prepends to each message */
|
|
58
|
+
const logSourceRegex = /^(\S+\.\d+)(?: \(\d+\))? (.*)$/;
|
|
59
|
+
/**
|
|
60
|
+
* Parses a line of the adapter output into a structured log message.
|
|
61
|
+
* Lines that are not in the ioBroker log format (e.g. plain `console.log` output)
|
|
62
|
+
* are returned as `info` messages.
|
|
63
|
+
*
|
|
64
|
+
* @param line A single line of the adapter's stdout/stderr
|
|
65
|
+
*/
|
|
66
|
+
function parseAdapterLogLine(line) {
|
|
67
|
+
const raw = line.replace(ansiRegex, '').trimEnd();
|
|
68
|
+
const match = logLineRegex.exec(raw);
|
|
69
|
+
const level = match?.[2].toLowerCase();
|
|
70
|
+
if (!match || !level || !logLevels.includes(level)) {
|
|
71
|
+
// Not an ioBroker log line, e.g. plain console output or a stack trace
|
|
72
|
+
return { level: 'info', timestamp: new Date(), from: undefined, message: raw, raw };
|
|
73
|
+
}
|
|
74
|
+
const sourceMatch = logSourceRegex.exec(match[3]);
|
|
75
|
+
return {
|
|
76
|
+
level,
|
|
77
|
+
timestamp: new Date(match[1]),
|
|
78
|
+
from: sourceMatch?.[1],
|
|
79
|
+
message: sourceMatch ? sourceMatch[2] : match[3],
|
|
80
|
+
raw,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
50
83
|
const fromAdapterID = 'system.adapter.test.0';
|
|
84
|
+
/**
|
|
85
|
+
* Encrypts or decrypts a value with the given secret. This is the same symmetric algorithm
|
|
86
|
+
* the JS-Controller uses for the `native` properties listed in `encryptedNative`,
|
|
87
|
+
* so applying it twice returns the original value.
|
|
88
|
+
*
|
|
89
|
+
* @param secret The secret from the `system.config` object
|
|
90
|
+
* @param value The value to encrypt or decrypt
|
|
91
|
+
*/
|
|
92
|
+
function encryptDecrypt(secret, value) {
|
|
93
|
+
let result = '';
|
|
94
|
+
for (let i = 0; i < value.length; ++i) {
|
|
95
|
+
result += String.fromCharCode(secret[i % secret.length].charCodeAt(0) ^ value.charCodeAt(i));
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
51
99
|
/**
|
|
52
100
|
* The test harness capsules the execution of the JS-Controller and the adapter instance and monitors their status.
|
|
53
101
|
* Use it in every test to start a fresh adapter instance
|
|
@@ -169,6 +217,7 @@ class TestHarness extends node_events_1.EventEmitter {
|
|
|
169
217
|
const mainFileRelative = path.relative(this.testAdapterDir, mainFileAbsolute);
|
|
170
218
|
const onClose = (code, signal) => {
|
|
171
219
|
this._adapterProcess.removeAllListeners();
|
|
220
|
+
this.flushAdapterOutput();
|
|
172
221
|
this._adapterExit = code != undefined ? code : signal;
|
|
173
222
|
this.emit('failed', this._adapterExit);
|
|
174
223
|
};
|
|
@@ -180,11 +229,14 @@ class TestHarness extends node_events_1.EventEmitter {
|
|
|
180
229
|
: [mainFileRelative, '--console'];
|
|
181
230
|
this._adapterProcess = (0, node_child_process_1.spawn)(command, args, {
|
|
182
231
|
cwd: this.testAdapterDir,
|
|
183
|
-
|
|
232
|
+
// stdout and stderr are piped, so the log messages can be captured
|
|
233
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
184
234
|
env: { ...process.env, ...env },
|
|
185
235
|
})
|
|
186
236
|
.on('close', onClose)
|
|
187
237
|
.on('exit', onClose);
|
|
238
|
+
this._adapterProcess.stdout?.on('data', (chunk) => this.handleAdapterOutput(chunk, 'stdout'));
|
|
239
|
+
this._adapterProcess.stderr?.on('data', (chunk) => this.handleAdapterOutput(chunk, 'stderr'));
|
|
188
240
|
}
|
|
189
241
|
/**
|
|
190
242
|
* Starts the adapter in a separate process and resolves after it has started
|
|
@@ -228,6 +280,7 @@ class TestHarness extends node_events_1.EventEmitter {
|
|
|
228
280
|
return;
|
|
229
281
|
}
|
|
230
282
|
this._adapterProcess.removeAllListeners();
|
|
283
|
+
this.flushAdapterOutput();
|
|
231
284
|
this._adapterExit = code != undefined ? code : signal;
|
|
232
285
|
this._adapterProcess = undefined;
|
|
233
286
|
debug('Adapter process terminated:');
|
|
@@ -250,16 +303,95 @@ class TestHarness extends node_events_1.EventEmitter {
|
|
|
250
303
|
});
|
|
251
304
|
}
|
|
252
305
|
/**
|
|
253
|
-
* Updates the adapter config. The changes can be a subset of the target object
|
|
306
|
+
* Updates the adapter config. The changes can be a subset of the target object.
|
|
307
|
+
* The `native` properties that are listed in the instance object's `encryptedNative`
|
|
308
|
+
* are encrypted automatically, so they can be passed in plain text.
|
|
254
309
|
*/
|
|
255
310
|
async changeAdapterConfig(adapterName, changes) {
|
|
256
311
|
const adapterInstanceId = `system.adapter.${adapterName}.0`;
|
|
257
312
|
const obj = await this.dbConnection.getObject(adapterInstanceId);
|
|
258
313
|
if (obj) {
|
|
259
|
-
(0, objects_1.extend)(obj, changes);
|
|
314
|
+
(0, objects_1.extend)(obj, await this.encryptNativeChanges(obj, changes));
|
|
260
315
|
await this.dbConnection.setObject(adapterInstanceId, obj);
|
|
261
316
|
}
|
|
262
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* Reads the config of an adapter instance. The `native` properties that are listed in the
|
|
320
|
+
* instance object's `encryptedNative` are decrypted automatically, so they are returned in plain text.
|
|
321
|
+
*
|
|
322
|
+
* @param adapterName The name of the adapter. Defaults to the adapter under test.
|
|
323
|
+
*/
|
|
324
|
+
async getAdapterConfig(adapterName = this.adapterName) {
|
|
325
|
+
const obj = await this.dbConnection.getObject(`system.adapter.${adapterName}.0`);
|
|
326
|
+
if (!obj) {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
const fields = this.getEncryptedFields(obj, obj.native);
|
|
330
|
+
if (fields.length) {
|
|
331
|
+
const secret = await this.getSystemSecret();
|
|
332
|
+
const native = { ...obj.native };
|
|
333
|
+
for (const field of fields) {
|
|
334
|
+
native[field] = encryptDecrypt(secret, native[field]);
|
|
335
|
+
}
|
|
336
|
+
debug(`Decrypted the following config fields: ${fields.join(', ')}`);
|
|
337
|
+
return { ...obj, native };
|
|
338
|
+
}
|
|
339
|
+
return obj;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Returns the names of all `native` properties in the given config that must be en-/decrypted
|
|
343
|
+
*/
|
|
344
|
+
getEncryptedFields(obj, native) {
|
|
345
|
+
if (!native || !obj.encryptedNative?.length) {
|
|
346
|
+
return [];
|
|
347
|
+
}
|
|
348
|
+
// Only strings can be en-/decrypted, everything else is left untouched
|
|
349
|
+
return obj.encryptedNative.filter(field => typeof native[field] === 'string');
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Encrypts all `native` properties of the given changes that are listed in the instance
|
|
353
|
+
* object's `encryptedNative`. Returns the changes to apply - the passed object is not modified.
|
|
354
|
+
*/
|
|
355
|
+
async encryptNativeChanges(obj, changes) {
|
|
356
|
+
const fields = this.getEncryptedFields(obj, changes.native);
|
|
357
|
+
if (!fields.length) {
|
|
358
|
+
return changes;
|
|
359
|
+
}
|
|
360
|
+
const secret = await this.getSystemSecret();
|
|
361
|
+
const native = { ...changes.native };
|
|
362
|
+
for (const field of fields) {
|
|
363
|
+
native[field] = encryptDecrypt(secret, native[field]);
|
|
364
|
+
}
|
|
365
|
+
debug(`Encrypted the following config fields: ${fields.join(', ')}`);
|
|
366
|
+
return { ...changes, native };
|
|
367
|
+
}
|
|
368
|
+
_systemSecret;
|
|
369
|
+
/**
|
|
370
|
+
* Reads the secret from the `system.config` object. The secret is cached after the first read.
|
|
371
|
+
*/
|
|
372
|
+
async getSystemSecret() {
|
|
373
|
+
if (this._systemSecret === undefined) {
|
|
374
|
+
const systemConfig = await this.dbConnection.getObject('system.config');
|
|
375
|
+
const secret = systemConfig?.native?.secret;
|
|
376
|
+
if (typeof secret !== 'string' || !secret) {
|
|
377
|
+
throw new Error('Could not read the secret from the object "system.config"!');
|
|
378
|
+
}
|
|
379
|
+
this._systemSecret = secret;
|
|
380
|
+
}
|
|
381
|
+
return this._systemSecret;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Encrypts a value the same way the JS-Controller does for `encryptedNative` properties
|
|
385
|
+
*/
|
|
386
|
+
async encryptValue(value) {
|
|
387
|
+
return encryptDecrypt(await this.getSystemSecret(), value);
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Decrypts a value that was encrypted for an `encryptedNative` property
|
|
391
|
+
*/
|
|
392
|
+
async decryptValue(value) {
|
|
393
|
+
return encryptDecrypt(await this.getSystemSecret(), value);
|
|
394
|
+
}
|
|
263
395
|
getAdapterExecutionMode() {
|
|
264
396
|
return (0, adapterTools_1.getAdapterExecutionMode)(this.testAdapterDir);
|
|
265
397
|
}
|
|
@@ -296,5 +428,63 @@ class TestHarness extends node_events_1.EventEmitter {
|
|
|
296
428
|
},
|
|
297
429
|
}, (err, id) => console.log(`published message ${id}`));
|
|
298
430
|
}
|
|
431
|
+
/** The log messages of the adapter under test */
|
|
432
|
+
_logs = [];
|
|
433
|
+
/** The incomplete last line of each output stream, waiting for the rest to arrive */
|
|
434
|
+
_outputBuffer = { stdout: '', stderr: '' };
|
|
435
|
+
/**
|
|
436
|
+
* Handles a chunk of the adapter's output. Because a chunk may end in the middle of a line,
|
|
437
|
+
* the incomplete rest is buffered until the remainder arrives.
|
|
438
|
+
*
|
|
439
|
+
* @param chunk The received chunk
|
|
440
|
+
* @param stream Which of the adapter's output streams the chunk was received on
|
|
441
|
+
*/
|
|
442
|
+
handleAdapterOutput(chunk, stream) {
|
|
443
|
+
const lines = (this._outputBuffer[stream] + chunk.toString()).split('\n');
|
|
444
|
+
// The last entry is either an incomplete line or empty - keep it for the next chunk
|
|
445
|
+
this._outputBuffer[stream] = lines.pop() ?? '';
|
|
446
|
+
for (const line of lines) {
|
|
447
|
+
this.handleAdapterOutputLine(line, stream);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/** Prints a line of the adapter's output and remembers it as a log message */
|
|
451
|
+
handleAdapterOutputLine(line, stream) {
|
|
452
|
+
// Forward the output, so it stays visible while the tests are running
|
|
453
|
+
process[stream].write(`${line}\n`);
|
|
454
|
+
if (line.trim()) {
|
|
455
|
+
this._logs.push(parseAdapterLogLine(line));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/** Handles the incomplete lines that were left over when the adapter exited */
|
|
459
|
+
flushAdapterOutput() {
|
|
460
|
+
for (const stream of ['stdout', 'stderr']) {
|
|
461
|
+
const rest = this._outputBuffer[stream];
|
|
462
|
+
this._outputBuffer[stream] = '';
|
|
463
|
+
if (rest) {
|
|
464
|
+
this.handleAdapterOutputLine(rest, stream);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Returns the log messages the adapter has printed so far
|
|
470
|
+
*
|
|
471
|
+
* @param level If given, only the messages with this log level are returned
|
|
472
|
+
*/
|
|
473
|
+
getLogs(level) {
|
|
474
|
+
return level ? this._logs.filter(log => log.level === level) : [...this._logs];
|
|
475
|
+
}
|
|
476
|
+
/** Forgets all log messages that were captured so far */
|
|
477
|
+
clearLogs() {
|
|
478
|
+
this._logs = [];
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Tests if the adapter has logged a message matching the given pattern
|
|
482
|
+
*
|
|
483
|
+
* @param pattern A RegExp or a string that must be contained in the message
|
|
484
|
+
* @param level If given, only the messages with this log level are checked
|
|
485
|
+
*/
|
|
486
|
+
hasLog(pattern, level) {
|
|
487
|
+
return this.getLogs(level).some(log => typeof pattern === 'string' ? log.message.includes(pattern) : pattern.test(log.message));
|
|
488
|
+
}
|
|
299
489
|
}
|
|
300
490
|
exports.TestHarness = TestHarness;
|
|
@@ -2,4 +2,6 @@
|
|
|
2
2
|
* Tests if the adapter files are valid.
|
|
3
3
|
* This is meant to be executed in a mocha context.
|
|
4
4
|
*/
|
|
5
|
-
export declare function validatePackageFiles(adapterDir: string
|
|
5
|
+
export declare function validatePackageFiles(adapterDir: string, options?: {
|
|
6
|
+
ignoreJsonConfigValidation?: boolean;
|
|
7
|
+
}): void;
|
|
@@ -42,11 +42,110 @@ const chai_1 = require("chai");
|
|
|
42
42
|
const fs = __importStar(require("fs"));
|
|
43
43
|
const json5_1 = __importDefault(require("json5"));
|
|
44
44
|
const path = __importStar(require("path"));
|
|
45
|
+
const ajv_1 = require("ajv");
|
|
46
|
+
const axios_1 = __importDefault(require("axios"));
|
|
47
|
+
const jsonValidators = {};
|
|
48
|
+
/** URL to the JSON config schema */
|
|
49
|
+
const JSON_CONFIG_SCHEMA_URL = 'https://raw.githubusercontent.com/ioBroker/json-config/main/schemas/jsonConfig.json';
|
|
50
|
+
/** Timeout for downloading the JSON config schema, so a hanging request cannot block the test run */
|
|
51
|
+
const JSON_CONFIG_SCHEMA_TIMEOUT_MS = 10000;
|
|
52
|
+
/**
|
|
53
|
+
* A JSON tab (`common.adminTab.link`) has the same format as `jsonConfig.json`, with two differences:
|
|
54
|
+
* its root may have a `command` (message that is sent to the instance when the tab is opened),
|
|
55
|
+
* and its root `type` may be omitted, because it defaults to `panel`.
|
|
56
|
+
*
|
|
57
|
+
* @param schema the jsonConfig schema. It will be modified in place
|
|
58
|
+
*/
|
|
59
|
+
function adaptSchemaForTab(schema) {
|
|
60
|
+
// The root of the schema is an "if type === 'tabs' then ... else ..." construction
|
|
61
|
+
const roots = [schema.then, schema.else].filter(root => !!root);
|
|
62
|
+
if (!roots.length) {
|
|
63
|
+
roots.push(schema);
|
|
64
|
+
}
|
|
65
|
+
for (const root of roots) {
|
|
66
|
+
root.properties ||= {};
|
|
67
|
+
root.properties.command = {
|
|
68
|
+
description: 'Message that is sent to the instance as the tab is opened',
|
|
69
|
+
type: 'string',
|
|
70
|
+
};
|
|
71
|
+
if (Array.isArray(root.required)) {
|
|
72
|
+
root.required = root.required.filter((name) => name !== 'type');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Compile the JSON schema for `jsonConfig.json` or for a JSON tab and cache the result,
|
|
78
|
+
* as the schema is quite big and it is used with every opened config page or tab
|
|
79
|
+
*
|
|
80
|
+
* @param type `config` for `admin/jsonConfig.json(5)`, `tab` for the JSON file of an admin tab
|
|
81
|
+
*/
|
|
82
|
+
async function getJsonValidator(type) {
|
|
83
|
+
const subType = type === 'custom' ? 'config' : type;
|
|
84
|
+
if (jsonValidators[subType]) {
|
|
85
|
+
return jsonValidators[subType];
|
|
86
|
+
}
|
|
87
|
+
let schema;
|
|
88
|
+
try {
|
|
89
|
+
console.debug(`retrieving json schema from ${JSON_CONFIG_SCHEMA_URL}`);
|
|
90
|
+
const schemaRes = await axios_1.default.get(JSON_CONFIG_SCHEMA_URL, { timeout: JSON_CONFIG_SCHEMA_TIMEOUT_MS });
|
|
91
|
+
schema = schemaRes.data;
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
console.error(`Could not get jsonConfig schema: ${e.message}`);
|
|
95
|
+
throw new Error(`Could not get jsonConfig schema`);
|
|
96
|
+
}
|
|
97
|
+
if (type === 'tab') {
|
|
98
|
+
adaptSchemaForTab(schema);
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const ajv = new ajv_1.Ajv({
|
|
102
|
+
allErrors: false,
|
|
103
|
+
strict: 'log',
|
|
104
|
+
});
|
|
105
|
+
jsonValidators[subType] = ajv.compile(schema);
|
|
106
|
+
return jsonValidators[subType];
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
console.debug(`Could not compile jsonConfig schema: ${e.message}`);
|
|
110
|
+
throw new Error(`Could not compile jsonConfig schema`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Checks that the given path exists and is a file, not a directory */
|
|
114
|
+
function isFile(filePath) {
|
|
115
|
+
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
|
116
|
+
}
|
|
117
|
+
async function validateJsonConfig(adapterDir, type = 'config', tabFile) {
|
|
118
|
+
let config;
|
|
119
|
+
if (type === 'config' && fs.existsSync(path.join(adapterDir, 'admin/jsonConfig.json'))) {
|
|
120
|
+
config = JSON.parse(fs.readFileSync(path.join(adapterDir, 'admin/jsonConfig.json'), 'utf-8'));
|
|
121
|
+
}
|
|
122
|
+
else if (type === 'config' && fs.existsSync(path.join(adapterDir, 'admin/jsonConfig.json5'))) {
|
|
123
|
+
config = json5_1.default.parse(fs.readFileSync(path.join(adapterDir, 'admin/jsonConfig.json5'), 'utf-8'));
|
|
124
|
+
}
|
|
125
|
+
else if (type === 'tab' && tabFile && isFile(path.join(adapterDir, `admin/${tabFile}`))) {
|
|
126
|
+
const tabPath = path.join(adapterDir, `admin/${tabFile}`);
|
|
127
|
+
const tabContent = fs.readFileSync(tabPath, 'utf-8');
|
|
128
|
+
config = tabFile.endsWith('5') ? json5_1.default.parse(tabContent) : JSON.parse(tabContent);
|
|
129
|
+
}
|
|
130
|
+
else if (type === 'custom' && fs.existsSync(path.join(adapterDir, `admin/jsonCustom.json`))) {
|
|
131
|
+
config = JSON.parse(fs.readFileSync(path.join(adapterDir, 'admin/jsonCustom.json'), 'utf-8'));
|
|
132
|
+
}
|
|
133
|
+
else if (type === 'custom' && fs.existsSync(path.join(adapterDir, `admin/jsonCustom.json5`))) {
|
|
134
|
+
config = json5_1.default.parse(fs.readFileSync(path.join(adapterDir, 'admin/jsonCustom.json5'), 'utf-8'));
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const validate = await getJsonValidator(type);
|
|
140
|
+
if (!validate(config)) {
|
|
141
|
+
throw new Error(`Invalid ${type} schema for ${adapterDir}: ${JSON.stringify(validate.errors, null, 2)}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
45
144
|
/**
|
|
46
145
|
* Tests if the adapter files are valid.
|
|
47
146
|
* This is meant to be executed in a mocha context.
|
|
48
147
|
*/
|
|
49
|
-
function validatePackageFiles(adapterDir) {
|
|
148
|
+
function validatePackageFiles(adapterDir, options) {
|
|
50
149
|
const packageJsonPath = path.join(adapterDir, 'package.json');
|
|
51
150
|
const ioPackageJsonPath = path.join(adapterDir, 'io-package.json');
|
|
52
151
|
// This allows us to skip tests that require valid JSON files
|
|
@@ -274,6 +373,34 @@ function validatePackageFiles(adapterDir) {
|
|
|
274
373
|
.true;
|
|
275
374
|
});
|
|
276
375
|
}
|
|
376
|
+
if (iopackContent.common.adminUI?.config === 'json') {
|
|
377
|
+
it('The JSON config file exists', () => {
|
|
378
|
+
(0, chai_1.expect)(fs.existsSync(path.join(adapterDir, 'admin/jsonConfig.json')) ||
|
|
379
|
+
fs.existsSync(path.join(adapterDir, 'admin/jsonConfig.json5')), 'common.adminUI.config is "json", so admin/jsonConfig.json or admin/jsonConfig.json5 must exist!').to.be.true;
|
|
380
|
+
});
|
|
381
|
+
if (!options?.ignoreJsonConfigValidation) {
|
|
382
|
+
it('Check JSON config file', () => validateJsonConfig(adapterDir, 'config'));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (iopackContent.common.adminUI?.custom === 'json') {
|
|
386
|
+
it('The JSON custom config file exists', () => {
|
|
387
|
+
(0, chai_1.expect)(fs.existsSync(path.join(adapterDir, 'admin/jsonCustom.json')) ||
|
|
388
|
+
fs.existsSync(path.join(adapterDir, 'admin/jsonCustom.json5')), 'common.adminUI.custom is "json", so admin/jsonCustom.json or admin/jsonCustom.json5 must exist!').to.be.true;
|
|
389
|
+
});
|
|
390
|
+
if (!options?.ignoreJsonConfigValidation) {
|
|
391
|
+
it('Check JSON custom config file', () => validateJsonConfig(adapterDir, 'custom'));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (iopackContent.common.adminUI?.tab === 'json') {
|
|
395
|
+
const link = (iopackContent.common.adminTab?.link || '').split('?')[0];
|
|
396
|
+
it('The JSON tab file is referenced correctly', () => {
|
|
397
|
+
(0, chai_1.expect)(link.endsWith('.json') || link.endsWith('.json5'), 'common.adminUI.tab is "json", so common.adminTab.link must point to a .json or .json5 file!').to.be.true;
|
|
398
|
+
(0, chai_1.expect)(!link.includes('..') && !link.includes('://') && !link.includes('%'), 'common.adminTab.link must be a file name relative to the admin directory!').to.be.true;
|
|
399
|
+
});
|
|
400
|
+
if (!options?.ignoreJsonConfigValidation) {
|
|
401
|
+
it('Check JSON tab file', () => validateJsonConfig(adapterDir, 'tab', link));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
277
404
|
});
|
|
278
405
|
describe(`Compare contents of package.json and io-package.json`, () => {
|
|
279
406
|
beforeEach(function () {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iobroker/testing",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.2.0",
|
|
4
4
|
"description": "Shared utilities for adapter and module testing in ioBroker",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"types": "build/index.d.ts",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
"author": "AlCalzone",
|
|
39
39
|
"license": "MIT",
|
|
40
40
|
"bugs": {
|
|
41
|
-
"url": "https://github.com/
|
|
41
|
+
"url": "https://github.com/ioBroker/testing/issues"
|
|
42
42
|
},
|
|
43
|
-
"homepage": "https://github.com/
|
|
43
|
+
"homepage": "https://github.com/ioBroker/testing#readme",
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@alcalzone/release-script": "^5.2.1",
|
|
46
46
|
"@alcalzone/release-script-plugin-license": "^5.2.2",
|
|
@@ -63,6 +63,8 @@
|
|
|
63
63
|
"@types/mocha": "^10.0.10",
|
|
64
64
|
"@types/sinon": "^22.0.0",
|
|
65
65
|
"@types/sinon-chai": "^3.2.12",
|
|
66
|
+
"ajv": "^8.20.0",
|
|
67
|
+
"axios": "^1.20.0",
|
|
66
68
|
"alcalzone-shared": "~5.0.0",
|
|
67
69
|
"chai": "^4.5.0",
|
|
68
70
|
"chai-as-promised": "^7.1.2",
|