@iobroker/testing 5.3.0 → 6.1.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/lib/executeCommand.d.ts +4 -2
- package/build/lib/executeCommand.js +21 -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.js +6 -0
- package/package.json +11 -8
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ This repo provides utilities for testing of ioBroker adapters and other ioBroker
|
|
|
7
7
|
|
|
8
8
|
The unit tests are realized using the following tools that are provided by this module:
|
|
9
9
|
|
|
10
|
-
- A mock database
|
|
10
|
+
- A mock database that implements the most basic functionality of `ioBroker`'s Objects and States DB by operating on `Map` objects.
|
|
11
11
|
- A mock `Adapter` that is connected to the mock database. It implements basic functionality of the real `Adapter` class, but only operates on the mock database.
|
|
12
12
|
|
|
13
13
|
Predefined methods for both unit and integration tests are exported.
|
|
@@ -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,6 +101,43 @@ 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.**
|
|
@@ -136,7 +187,7 @@ This method creates a mock database and a mock adapter. See below for a more det
|
|
|
136
187
|
const asserts = utils.unit.createAsserts(database, adapter);
|
|
137
188
|
```
|
|
138
189
|
|
|
139
|
-
These methods take a mock database and adapter and create a set of
|
|
190
|
+
These methods take a mock database and adapter and create a set of assertions for your tests. All IDs may either be a string, which is taken literally, or an array of strings which are concatenated with `"."`. If an ID is not fully qualified, the adapter namespace is prepended automatically.
|
|
140
191
|
|
|
141
192
|
- `assertObjectExists(id: string | string[])` asserts that an object with the given ID exists in the database.
|
|
142
193
|
- `assertStateExists(id: string | string[])` asserts that a state with the given ID exists in the database.
|
|
@@ -217,7 +268,7 @@ Resetting the mock between tests:
|
|
|
217
268
|
|
|
218
269
|
### Example
|
|
219
270
|
|
|
220
|
-
Here's an example how this can be used in a unit test:
|
|
271
|
+
Here's an example of how this can be used in a unit test:
|
|
221
272
|
|
|
222
273
|
```ts
|
|
223
274
|
import { tests, utils } from "@iobroker/testing";
|
|
@@ -5,9 +5,9 @@ export interface ExecuteCommandOptions {
|
|
|
5
5
|
cwd: string;
|
|
6
6
|
/** Where to redirect the stdin. Default: process.stdin */
|
|
7
7
|
stdin: NodeJS.ReadStream;
|
|
8
|
-
/** A
|
|
8
|
+
/** A writing stream to redirect the stdout, "ignore" to ignore it or "pipe" to return it as a string. Default: process.stdout */
|
|
9
9
|
stdout: NodeJS.WriteStream | 'pipe' | 'ignore';
|
|
10
|
-
/** A
|
|
10
|
+
/** A writing stream to redirect the stderr, "ignore" to ignore it or "pipe" to return it as a string. Default: process.stderr */
|
|
11
11
|
stderr: NodeJS.WriteStream | 'pipe' | 'ignore';
|
|
12
12
|
}
|
|
13
13
|
export interface ExecuteCommandResult {
|
|
@@ -19,6 +19,8 @@ export interface ExecuteCommandResult {
|
|
|
19
19
|
stdout?: string;
|
|
20
20
|
/** If options.stderr was set to "buffer", this contains the stderr of the spawned process */
|
|
21
21
|
stderr?: string;
|
|
22
|
+
/** The error that prevented the process from being spawned, if it could not be started at all */
|
|
23
|
+
error?: Error;
|
|
22
24
|
}
|
|
23
25
|
export declare function executeCommand(command: string, options?: Partial<ExecuteCommandOptions>): Promise<ExecuteCommandResult>;
|
|
24
26
|
/**
|
|
@@ -55,7 +55,22 @@ function executeCommand(command, argsOrOptions, options) {
|
|
|
55
55
|
try {
|
|
56
56
|
let bufferedStdout;
|
|
57
57
|
let bufferedStderr;
|
|
58
|
-
const cmd = (0, node_child_process_1.spawn)(command, args, spawnOptions)
|
|
58
|
+
const cmd = (0, node_child_process_1.spawn)(command, args, spawnOptions)
|
|
59
|
+
.on('error', error => {
|
|
60
|
+
// The process could not be spawned at all - e.g. the command does not
|
|
61
|
+
// exist or the cwd is missing. Without a listener, Node treats this as
|
|
62
|
+
// an unhandled 'error' event and tears down the entire test process
|
|
63
|
+
// with a stack trace from node:internal, which tells the adapter
|
|
64
|
+
// developer nothing about the actual cause.
|
|
65
|
+
// Node emits 'close' after 'error' in this case; that second resolve
|
|
66
|
+
// is a no-op because the promise is already settled.
|
|
67
|
+
resolve({
|
|
68
|
+
error,
|
|
69
|
+
stdout: bufferedStdout,
|
|
70
|
+
stderr: bufferedStderr,
|
|
71
|
+
});
|
|
72
|
+
})
|
|
73
|
+
.on('close', (code, signal) => {
|
|
59
74
|
resolve({
|
|
60
75
|
exitCode: code ?? undefined,
|
|
61
76
|
signal: signal ?? undefined,
|
|
@@ -83,8 +98,11 @@ function executeCommand(command, argsOrOptions, options) {
|
|
|
83
98
|
});
|
|
84
99
|
}
|
|
85
100
|
}
|
|
86
|
-
catch {
|
|
87
|
-
//
|
|
101
|
+
catch (error) {
|
|
102
|
+
// `spawn` can also throw synchronously, e.g. on invalid arguments. There is no
|
|
103
|
+
// child process in that case, so neither 'close' nor 'error' will ever fire and
|
|
104
|
+
// the promise would stay pending forever.
|
|
105
|
+
resolve({ error: error });
|
|
88
106
|
}
|
|
89
107
|
});
|
|
90
108
|
}
|
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;
|
|
@@ -166,6 +166,12 @@ function validatePackageFiles(adapterDir) {
|
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
});
|
|
169
|
+
it('No "prepare" script is defined in package.json', () => {
|
|
170
|
+
if ((0, typeguards_1.isObject)(packageContent.scripts) && 'prepare' in packageContent.scripts) {
|
|
171
|
+
// eslint-disable-next-line @typescript-eslint/only-throw-error
|
|
172
|
+
throw new chai_1.AssertionError(`The "prepare" script must not be defined in the "scripts" section of package.json, found "${packageContent.scripts.prepare}"! It runs on every "npm install" of the adapter (including for end users) and can break installations.`);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
169
175
|
it('iobroker.js-controller is not listed as a dependency', () => {
|
|
170
176
|
for (const depType of [
|
|
171
177
|
'dependencies',
|
package/package.json
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iobroker/testing",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.1.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",
|
|
7
7
|
"files": [
|
|
8
8
|
"build/"
|
|
9
9
|
],
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=22.19.0"
|
|
12
|
+
},
|
|
10
13
|
"scripts": {
|
|
11
14
|
"test": "mocha \"src/**/*.test.ts\"",
|
|
12
15
|
"test:watch": "mocha \"src/**/*.test.ts\" --watch",
|
|
@@ -40,14 +43,14 @@
|
|
|
40
43
|
"homepage": "https://github.com/AlCalzone/testing#readme",
|
|
41
44
|
"devDependencies": {
|
|
42
45
|
"@alcalzone/release-script": "^5.2.1",
|
|
43
|
-
"@alcalzone/release-script-plugin-license": "^5.2.
|
|
44
|
-
"@iobroker/adapter-core": "^3.4.
|
|
46
|
+
"@alcalzone/release-script-plugin-license": "^5.2.2",
|
|
47
|
+
"@iobroker/adapter-core": "^3.4.3",
|
|
45
48
|
"@iobroker/eslint-config": "^2.3.4",
|
|
46
49
|
"@iobroker/types": "^7.2.2",
|
|
47
|
-
"@tsconfig/node18": "^18.2.
|
|
50
|
+
"@tsconfig/node18": "^18.2.7",
|
|
48
51
|
"@types/debug": "^4.1.13",
|
|
49
52
|
"@types/fs-extra": "^11.0.4",
|
|
50
|
-
"@types/node": "^
|
|
53
|
+
"@types/node": "^22.20.1",
|
|
51
54
|
"rimraf": "^6.1.3",
|
|
52
55
|
"source-map-support": "^0.5.21",
|
|
53
56
|
"ts-node": "^10.9.2",
|
|
@@ -64,10 +67,10 @@
|
|
|
64
67
|
"chai": "^4.5.0",
|
|
65
68
|
"chai-as-promised": "^7.1.2",
|
|
66
69
|
"debug": "^4.4.3",
|
|
67
|
-
"fs-extra": "^11.
|
|
70
|
+
"fs-extra": "^11.4.0",
|
|
68
71
|
"json5": "^2.2.3",
|
|
69
|
-
"mocha": "^
|
|
70
|
-
"sinon": "^22.
|
|
72
|
+
"mocha": "^12.0.0",
|
|
73
|
+
"sinon": "^22.1.0",
|
|
71
74
|
"sinon-chai": "^3.7.0"
|
|
72
75
|
}
|
|
73
76
|
}
|