@iobroker/testing 5.2.1 → 5.3.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2024-2025 AlCalzone <d.griesel@gmx.net>
3
+ Copyright (c) 2024-2026 AlCalzone <d.griesel@gmx.net>
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -148,11 +148,72 @@ These methods take a mock database and adapter and create a set of asserts for y
148
148
 
149
149
  #### MockDatabase
150
150
 
151
- TODO
151
+ `MockDatabase` is a minimalistic reimplementation of ioBroker's Objects and States DB that operates purely on in-memory `Map`s. It is created for you by `createMocks()`, but you can also instantiate it directly:
152
+
153
+ ```ts
154
+ const { MockDatabase } = require("@iobroker/testing");
155
+ const database = new MockDatabase();
156
+ ```
157
+
158
+ Objects and states are stored under their fully qualified ID (e.g. `"adapter.0.some.state"`). Wherever a method comes in two overloads, you can either pass the full ID as a single string or pass the namespace and the rest of the ID separately (they are joined with `"."`). Pattern-based getters accept `ioBroker`-style wildcards (`*`).
159
+
160
+ Populating the database:
161
+
162
+ - `publishObject(obj: ioBroker.PartialObject)` adds or replaces an object. The object **must** have an `_id` and a `type`; missing `common`/`native` are filled from a default template.
163
+ - `publishObjects(...objects)` publishes multiple objects at once.
164
+ - `publishStateObjects(...objects)` / `publishChannelObjects(...objects)` / `publishDeviceObjects(...objects)` publish objects while forcing their `type` to `state` / `channel` / `device`.
165
+ - `publishState(id: string, state: Partial<ioBroker.State> | null | undefined)` sets a state (missing properties are filled from a default template). Passing `null`/`undefined` deletes it.
166
+ - `publishStates(states: Record<string, Partial<ioBroker.State> | null | undefined>)` sets multiple states at once.
167
+
168
+ Reading and querying:
169
+
170
+ - `hasObject(id)` / `hasObject(namespace, id)` returns whether an object exists.
171
+ - `getObject(id)` / `getObject(namespace, id)` returns an object or `undefined`.
172
+ - `getObjects(pattern, type?)` / `getObjects(namespace, pattern, type?)` returns all matching objects as a `Record<string, ioBroker.Object>`, optionally filtered by `type`.
173
+ - `hasState(id)` / `hasState(namespace, id)` returns whether a state exists.
174
+ - `getState(id)` / `getState(namespace, id)` returns a state or `undefined`.
175
+ - `getStates(pattern)` returns all matching states as a `Record<string, ioBroker.State>`.
176
+
177
+ Deleting and resetting:
178
+
179
+ - `deleteObject(objOrID)` removes an object (accepts the object or its ID).
180
+ - `deleteState(id)` removes a state.
181
+ - `clearObjects()` / `clearStates()` empty the respective store, `clear()` empties both. Call this between tests to start with a fresh database.
152
182
 
153
183
  #### MockAdapter
154
184
 
155
- TODO
185
+ `MockAdapter` is a mock of ioBroker's `Adapter` class in which every method is replaced by a [Sinon stub](https://sinonjs.org/releases/latest/stubs/). It is created by `createMocks()` and is backed by a `MockDatabase`, so the DB-related methods actually read from and write to that database instead of a real DB.
186
+
187
+ ```ts
188
+ const { database, adapter } = utils.unit.createMocks();
189
+ // or with custom adapter options (name, instance, config, ...)
190
+ const { database, adapter } = utils.unit.createMocks({ name: "my-adapter" });
191
+ ```
192
+
193
+ Because the methods are Sinon stubs, you can assert on how your adapter code called them and override their behavior in individual tests:
194
+
195
+ ```ts
196
+ // Assert that a state was set
197
+ adapter.setState.callCount.should.equal(1);
198
+ adapter.setState.getCall(0).args[0].should.equal("info.connection");
199
+
200
+ // Override the return value / behavior for a single test
201
+ adapter.getForeignObjectAsync.resolves({ common: { name: "test" } });
202
+ ```
203
+
204
+ Key features:
205
+
206
+ - **Connected to the database:** methods like `getObject`, `setObject`, `setObjectNotExists`, `extendObject`, `getState`, `setState`, `setStateChanged`, `delState`, their `Foreign` variants and `getObjectView` / `getObjectList` are implemented to operate on the backing `MockDatabase`. IDs that are not fully qualified are automatically prefixed with the adapter namespace. The remaining methods are plain stubs that do nothing until you give them a behavior.
207
+ - **Promisified methods:** for every callback-style method, the corresponding `...Async` version (e.g. `getStateAsync`) is available, just like on a real adapter.
208
+ - **Event handlers:** the handlers your adapter registers (via `adapter.on(...)` or the adapter options) are captured and exposed so you can invoke them from your tests: `readyHandler`, `stateChangeHandler`, `objectChangeHandler`, `messageHandler` and `unloadHandler`. For example, call `await adapter.readyHandler()` to simulate the adapter's startup.
209
+ - **Logger:** `adapter.log` is a `MockLogger` whose `debug` / `info` / `warn` / `error` methods are stubs you can assert on.
210
+ - **`terminate()`** throws an error (carrying the exit reason) instead of exiting the process, so you can assert that your adapter tried to terminate.
211
+
212
+ Resetting the mock between tests:
213
+
214
+ - `resetMockHistory()` clears the recorded call history (call counts, arguments, ...) of all stubs and the logger, but keeps any custom behavior you configured.
215
+ - `resetMockBehavior()` restores the default behavior of all stubs, discarding your overrides.
216
+ - `resetMock()` does both.
156
217
 
157
218
  ### Example
158
219
 
@@ -51,7 +51,7 @@ exports.getAdapterDependencies = getAdapterDependencies;
51
51
  const typeguards_1 = require("alcalzone-shared/typeguards");
52
52
  const debug_1 = __importDefault(require("debug"));
53
53
  const fs_extra_1 = require("fs-extra");
54
- const path = __importStar(require("path"));
54
+ const path = __importStar(require("node:path"));
55
55
  const debug = (0, debug_1.default)('testing:unit:adapterTools');
56
56
  /**
57
57
  * Loads an adapter's package.json
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.executeCommand = executeCommand;
4
- const child_process_1 = require("child_process");
4
+ const node_child_process_1 = require("node:child_process");
5
5
  const isWindows = /^win/.test(process.platform);
6
6
  /**
7
7
  * Executes a command and returns the exit code and (if requested) the stdout
@@ -55,7 +55,7 @@ function executeCommand(command, argsOrOptions, options) {
55
55
  try {
56
56
  let bufferedStdout;
57
57
  let bufferedStderr;
58
- const cmd = (0, child_process_1.spawn)(command, args, spawnOptions).on('close', (code, signal) => {
58
+ const cmd = (0, node_child_process_1.spawn)(command, args, spawnOptions).on('close', (code, signal) => {
59
59
  resolve({
60
60
  exitCode: code ?? undefined,
61
61
  signal: signal ?? undefined,
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.str2regex = str2regex;
4
4
  function str2regex(pattern) {
5
5
  return new RegExp(pattern
6
- .replace(/\\/g, '\\\\') // Backslashes escapen
6
+ .replace(/\\/g, '\\\\') // Backslashes escape
7
7
  .replace(/\./g, '\\.') // Punkte als solche matchen
8
8
  .replace(/\*/g, '.*') // Wildcard in Regex umsetzen
9
9
  .replace(/!/g, '?!'));
@@ -6,7 +6,9 @@ export interface TestAdapterOptions {
6
6
  /** How long to wait before the adapter startup is considered successful */
7
7
  waitBeforeStartupSuccess?: number;
8
8
  /**
9
- * Which JS-Controller version or dist-tag should be used for the tests. Default: dev
9
+ * Which JS-Controller version or dist-tag should be used for the tests.
10
+ * Default: `dev`, except on Node.js <= 20 where it defaults to `7.2.2`, the last
11
+ * js-controller version that still supports Node.js 18 and 20.
10
12
  * This should only be changed during active development.
11
13
  */
12
14
  controllerVersion?: string;
@@ -46,6 +46,8 @@ const executeCommand_1 = require("../../../lib/executeCommand");
46
46
  const tools_1 = require("./tools");
47
47
  const debug = (0, debug_1.default)('testing:integration:AdapterSetup');
48
48
  class AdapterSetup {
49
+ adapterDir;
50
+ testDir;
49
51
  constructor(adapterDir, testDir) {
50
52
  this.adapterDir = adapterDir;
51
53
  this.testDir = testDir;
@@ -61,6 +63,11 @@ class AdapterSetup {
61
63
  debug(` appName: ${this.appName}`);
62
64
  debug(` adapterName: ${this.adapterName}`);
63
65
  }
66
+ testAdapterDir;
67
+ adapterName;
68
+ adapterFullName;
69
+ appName;
70
+ testControllerDir;
64
71
  /**
65
72
  * Tests if the adapter is already installed in the test directory
66
73
  */
@@ -47,6 +47,8 @@ const executeCommand_1 = require("../../../lib/executeCommand");
47
47
  const tools_1 = require("./tools");
48
48
  const debug = (0, debug_1.default)('testing:integration:ControllerSetup');
49
49
  class ControllerSetup {
50
+ adapterDir;
51
+ testDir;
50
52
  constructor(adapterDir, testDir) {
51
53
  this.adapterDir = adapterDir;
52
54
  this.testDir = testDir;
@@ -63,7 +65,19 @@ class ControllerSetup {
63
65
  debug(` appName: ${this.appName}`);
64
66
  debug(` adapterName: ${this.adapterName}`);
65
67
  }
66
- async prepareTestDir(controllerVersion = 'dev') {
68
+ appName;
69
+ adapterName;
70
+ testAdapterDir;
71
+ testControllerDir;
72
+ testDataDir;
73
+ async prepareTestDir(controllerVersion) {
74
+ const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10);
75
+ // js-controller 7.2.3 dropped support for Node.js 18 and 20. If no specific
76
+ // version was requested, and we are running on such a Node.js version, pin
77
+ // js-controller to the last version that still supports it.
78
+ if (!controllerVersion) {
79
+ controllerVersion = nodeMajorVersion <= 20 ? '7.2.2' : 'dev';
80
+ }
67
81
  debug(`Preparing the test directory. JS-Controller version: "${controllerVersion}"...`);
68
82
  // Make sure the test dir exists
69
83
  await (0, fs_extra_1.ensureDir)(this.testDir);
@@ -94,7 +108,6 @@ class ControllerSetup {
94
108
  await (0, fs_extra_1.unlink)(pckLockPath);
95
109
  }
96
110
  // Set the engineStrict flag on new Node.js versions to be in line with newer ioBroker installations
97
- const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10);
98
111
  if (nodeMajorVersion >= 10) {
99
112
  await (0, fs_extra_1.writeFile)(path.join(this.testDir, '.npmrc'), 'engine-strict=true', 'utf8');
100
113
  }
@@ -45,6 +45,9 @@ const tools_1 = require("./tools");
45
45
  const debug = (0, debug_1.default)('testing:integration:DBConnection');
46
46
  /** The DB connection capsules access to the states and objects DB */
47
47
  class DBConnection extends node_events_1.default {
48
+ appName;
49
+ testDir;
50
+ logger;
48
51
  /**
49
52
  * @param appName The branded name of "iobroker"
50
53
  * @param testDir The directory the integration tests are executed in
@@ -55,63 +58,20 @@ class DBConnection extends node_events_1.default {
55
58
  this.appName = appName;
56
59
  this.testDir = testDir;
57
60
  this.logger = logger;
58
- this._isRunning = false;
59
- this.getObject = id => {
60
- if (!this._objectsClient) {
61
- throw new Error('Objects DB is not running');
62
- }
63
- return this._objectsClient.getObjectAsync(id);
64
- };
65
- this.setObject = (...args) => {
66
- if (!this._objectsClient) {
67
- throw new Error('Objects DB is not running');
68
- }
69
- return this._objectsClient.setObjectAsync(...args);
70
- };
71
- this.delObject = (...args) => {
72
- if (!this._objectsClient) {
73
- throw new Error('Objects DB is not running');
74
- }
75
- return this._objectsClient.delObjectAsync(...args);
76
- };
77
- this.getState = (id) => {
78
- if (!this._statesClient) {
79
- throw new Error('States DB is not running');
80
- }
81
- return this._statesClient.getStateAsync(id);
82
- };
83
- this.setState = ((...args) => {
84
- if (!this._statesClient) {
85
- throw new Error('States DB is not running');
86
- }
87
- return this._statesClient.setStateAsync(...args);
88
- });
89
- this.delState = async (...args) => {
90
- if (!this._statesClient) {
91
- throw new Error('States DB is not running');
92
- }
93
- await new Promise((resolve, reject) => this._statesClient.delState(args[0], (err) => {
94
- if (err) {
95
- reject(err);
96
- }
97
- else {
98
- resolve();
99
- }
100
- }));
101
- };
102
- this.getObjectViewAsync = (...args) => {
103
- if (!this._objectsClient) {
104
- throw new Error('Objects DB is not running');
105
- }
106
- return this._objectsClient.getObjectViewAsync(...args);
107
- };
108
61
  this.testControllerDir = (0, tools_1.getTestControllerDir)(this.appName, testDir);
109
62
  this.testDataDir = (0, tools_1.getTestDataDir)(appName, testDir);
110
63
  }
64
+ testDataDir;
65
+ testControllerDir;
66
+ // TODO: These could use some better type definitions
67
+ _objectsServer;
68
+ _statesServer;
69
+ _objectsClient;
111
70
  /** The underlying objects client instance that can be used to access the objects DB */
112
71
  get objectsClient() {
113
72
  return this._objectsClient;
114
73
  }
74
+ _statesClient;
115
75
  /** The underlying states client instance that can be used to access the states DB */
116
76
  get statesClient() {
117
77
  return this._statesClient;
@@ -157,6 +117,7 @@ class DBConnection extends node_events_1.default {
157
117
  const systemFilename = path.join(this.testDataDir, `${this.appName}.json`);
158
118
  (0, fs_extra_1.writeJSONSync)(systemFilename, systemConfig, { spaces: 2 });
159
119
  }
120
+ _isRunning = false;
160
121
  get isRunning() {
161
122
  return this._isRunning;
162
123
  }
@@ -277,6 +238,49 @@ class DBConnection extends node_events_1.default {
277
238
  });
278
239
  debug(' => done!');
279
240
  }
241
+ getObject = id => {
242
+ if (!this._objectsClient) {
243
+ throw new Error('Objects DB is not running');
244
+ }
245
+ return this._objectsClient.getObjectAsync(id);
246
+ };
247
+ setObject = (...args) => {
248
+ if (!this._objectsClient) {
249
+ throw new Error('Objects DB is not running');
250
+ }
251
+ return this._objectsClient.setObjectAsync(...args);
252
+ };
253
+ delObject = (...args) => {
254
+ if (!this._objectsClient) {
255
+ throw new Error('Objects DB is not running');
256
+ }
257
+ return this._objectsClient.delObjectAsync(...args);
258
+ };
259
+ getState = (id) => {
260
+ if (!this._statesClient) {
261
+ throw new Error('States DB is not running');
262
+ }
263
+ return this._statesClient.getStateAsync(id);
264
+ };
265
+ setState = ((...args) => {
266
+ if (!this._statesClient) {
267
+ throw new Error('States DB is not running');
268
+ }
269
+ return this._statesClient.setStateAsync(...args);
270
+ });
271
+ delState = async (...args) => {
272
+ if (!this._statesClient) {
273
+ throw new Error('States DB is not running');
274
+ }
275
+ await new Promise((resolve, reject) => this._statesClient.delState(args[0], (err) => {
276
+ if (err) {
277
+ reject(err);
278
+ }
279
+ else {
280
+ resolve();
281
+ }
282
+ }));
283
+ };
280
284
  subscribeMessage(id) {
281
285
  if (!this._statesClient) {
282
286
  throw new Error('States DB is not running');
@@ -289,6 +293,12 @@ class DBConnection extends node_events_1.default {
289
293
  }
290
294
  this._statesClient.pushMessage(instanceId, msg, callback);
291
295
  }
296
+ getObjectViewAsync = (...args) => {
297
+ if (!this._objectsClient) {
298
+ throw new Error('Objects DB is not running');
299
+ }
300
+ return this._objectsClient.getObjectViewAsync(...args);
301
+ };
292
302
  getStateIDs(pattern = '*') {
293
303
  if (!this._statesClient) {
294
304
  throw new Error('States DB is not running');
@@ -53,6 +53,9 @@ const fromAdapterID = 'system.adapter.test.0';
53
53
  * Use it in every test to start a fresh adapter instance
54
54
  */
55
55
  class TestHarness extends node_events_1.EventEmitter {
56
+ adapterDir;
57
+ testDir;
58
+ dbConnection;
56
59
  /**
57
60
  * @param adapterDir The root directory of the adapter
58
61
  * @param testDir The directory the integration tests are executed in
@@ -62,7 +65,6 @@ class TestHarness extends node_events_1.EventEmitter {
62
65
  this.adapterDir = adapterDir;
63
66
  this.testDir = testDir;
64
67
  this.dbConnection = dbConnection;
65
- this.sendToID = 1;
66
68
  debug('Creating instance');
67
69
  this.adapterName = (0, adapterTools_1.getAdapterName)(this.adapterDir);
68
70
  this.appName = (0, adapterTools_1.getAppName)(adapterDir);
@@ -80,6 +82,10 @@ class TestHarness extends node_events_1.EventEmitter {
80
82
  this.emit('stateChange', id, state);
81
83
  });
82
84
  }
85
+ adapterName;
86
+ appName;
87
+ testControllerDir;
88
+ testAdapterDir;
83
89
  /** Gives direct access to the Objects DB */
84
90
  get objects() {
85
91
  if (!this.dbConnection.objectsClient) {
@@ -94,10 +100,12 @@ class TestHarness extends node_events_1.EventEmitter {
94
100
  }
95
101
  return this.dbConnection.statesClient;
96
102
  }
103
+ _adapterProcess;
97
104
  /** The process the adapter is running in */
98
105
  get adapterProcess() {
99
106
  return this._adapterProcess;
100
107
  }
108
+ _adapterExit;
101
109
  /** Contains the adapter exit code or signal if it was terminated unexpectedly */
102
110
  get adapterExit() {
103
111
  return this._adapterExit;
@@ -266,6 +274,7 @@ class TestHarness extends node_events_1.EventEmitter {
266
274
  });
267
275
  this.dbConnection.subscribeMessage(fromAdapterID);
268
276
  }
277
+ sendToID = 1;
269
278
  /** Sends a message to an adapter instance */
270
279
  sendTo(target, command, message, callback) {
271
280
  const stateChangedHandler = (id, state) => {
@@ -291,12 +291,26 @@ function validatePackageFiles(adapterDir) {
291
291
  });
292
292
  });
293
293
  describe(`Validate JSON files`, () => {
294
+ // Validate base directory JSON files
295
+ describe(`Base directory JSON files`, () => {
296
+ for (const filename of ['package.json', 'io-package.json']) {
297
+ const filePath = path.join(adapterDir, filename);
298
+ if (fs.existsSync(filePath)) {
299
+ it(`${filename} contains valid JSON`, () => {
300
+ (0, chai_1.expect)(() => {
301
+ JSON.parse(fs.readFileSync(filePath, 'utf8'));
302
+ }, `${filename} contains invalid JSON!`).not.to.throw();
303
+ });
304
+ }
305
+ }
306
+ });
294
307
  // Find all JSON and JSON5 files in admin/ directory (recursively)
295
308
  const adminDir = path.join(adapterDir, 'admin');
296
309
  const allAdminJsonFiles = findFiles(adminDir, /\.json$/);
297
310
  const allAdminJson5Files = findFiles(adminDir, /\.json5$/);
298
311
  // Split JSON files into admin/*.json and admin/i18n/**/*.json
299
- const adminDirectJsonFiles = allAdminJsonFiles.filter(file => !file.includes(`${path.sep}i18n${path.sep}`));
312
+ // Exclude tsconfig.json as it may contain JSON5 syntax (comments, trailing commas)
313
+ const adminDirectJsonFiles = allAdminJsonFiles.filter(file => !file.includes(`${path.sep}i18n${path.sep}`) && !file.endsWith(`${path.sep}tsconfig.json`));
300
314
  const i18nJsonFiles = allAdminJsonFiles.filter(file => file.includes(`${path.sep}i18n${path.sep}`));
301
315
  if (adminDirectJsonFiles.length > 0) {
302
316
  describe(`admin/*.json files`, () => {
@@ -17,10 +17,8 @@ const stateTemplate = Object.freeze({
17
17
  * A minimalistic version of ioBroker's Objects and States DB that just operates on a Map
18
18
  */
19
19
  class MockDatabase {
20
- constructor() {
21
- this.objects = new Map();
22
- this.states = new Map();
23
- }
20
+ objects = new Map();
21
+ states = new Map();
24
22
  clearObjects() {
25
23
  this.objects.clear();
26
24
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iobroker/testing",
3
- "version": "5.2.1",
3
+ "version": "5.3.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",
@@ -39,35 +39,35 @@
39
39
  },
40
40
  "homepage": "https://github.com/AlCalzone/testing#readme",
41
41
  "devDependencies": {
42
- "@alcalzone/release-script": "^5.0.0",
43
- "@alcalzone/release-script-plugin-license": "^4.0.0",
44
- "@iobroker/adapter-core": "^3.3.2",
45
- "@iobroker/eslint-config": "^2.2.0",
46
- "@iobroker/types": "^7.0.7",
47
- "@tsconfig/node16": "^16.1.6",
48
- "@types/debug": "^4.1.12",
42
+ "@alcalzone/release-script": "^5.2.1",
43
+ "@alcalzone/release-script-plugin-license": "^5.2.0",
44
+ "@iobroker/adapter-core": "^3.4.1",
45
+ "@iobroker/eslint-config": "^2.3.4",
46
+ "@iobroker/types": "^7.2.2",
47
+ "@tsconfig/node18": "^18.2.6",
48
+ "@types/debug": "^4.1.13",
49
49
  "@types/fs-extra": "^11.0.4",
50
- "@types/node": "^24.6.1",
51
- "rimraf": "^6.1.0",
50
+ "@types/node": "^26.1.1",
51
+ "rimraf": "^6.1.3",
52
52
  "source-map-support": "^0.5.21",
53
53
  "ts-node": "^10.9.2",
54
- "typescript": "~5.9.3"
54
+ "typescript": "~6.0.3"
55
55
  },
56
56
  "dependencies": {
57
57
  "@alcalzone/esbuild-register": "^2.5.1-1",
58
58
  "@types/chai": "^4.3.20",
59
59
  "@types/chai-as-promised": "^7.1.8",
60
60
  "@types/mocha": "^10.0.10",
61
- "@types/sinon": "^17.0.4",
61
+ "@types/sinon": "^22.0.0",
62
62
  "@types/sinon-chai": "^3.2.12",
63
63
  "alcalzone-shared": "~5.0.0",
64
64
  "chai": "^4.5.0",
65
65
  "chai-as-promised": "^7.1.2",
66
66
  "debug": "^4.4.3",
67
- "fs-extra": "^11.3.2",
67
+ "fs-extra": "^11.3.6",
68
68
  "json5": "^2.2.3",
69
- "mocha": "^11.7.3",
70
- "sinon": "^21.0.0",
69
+ "mocha": "^11.7.6",
70
+ "sinon": "^22.0.0",
71
71
  "sinon-chai": "^3.7.0"
72
72
  }
73
73
  }