Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
PLACEHOLDER for the next version:
## **WORK IN PROGRESS**
-->
## **WORK IN PROGRESS**
* (@hombach) Fixed: `@iobroker/testing` no longer pulls `esbuild@0.11` (GHSA-67mh-4wv8-2f99) into every adapter. `@alcalzone/esbuild-register` is removed from the dependencies. TypeScript adapters are now started with [`tsx`](https://tsx.is) (`node --import tsx`) instead of `@alcalzone/esbuild-register`. `tsx` is installed in the integration test directory only and depends on a current `esbuild`

## 6.3.1 (2026-10-13)
* (Garfonso) Fixed: with npm >= 12 the test installation stayed empty, because npm blocks the install scripts of js-controller (`setup first`) and esbuild. Both are now listed in `allowScripts` of the generated `package.json`

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ This is the core of the library. `testAdapter()` in `integration/index.ts` build
3. **`shutdownTests` (after)** — stops the controller and adapter.

The collaborating classes in `integration/lib/`:
- **`TestHarness`** (`harness.ts`) — an `EventEmitter` that is the object user tests interact with. Spawns the adapter in a child process (`node`, or `node -r @alcalzone/esbuild-register` for `.ts` main files), monitors its `alive`/exit state, and exposes `startAdapter`, `startAdapterAndWait`, `stopAdapter`, `changeAdapterConfig`, `sendTo` (message-box emulation via `enableSendTo`), and direct `.objects` / `.states` DB access.
- **`TestHarness`** (`harness.ts`) — an `EventEmitter` that is the object user tests interact with. Spawns the adapter in a child process (`node`, or `node --import tsx` for `.ts` main files), monitors its `alive`/exit state, and exposes `startAdapter`, `startAdapterAndWait`, `stopAdapter`, `changeAdapterConfig`, `sendTo` (message-box emulation via `enableSendTo`), and direct `.objects` / `.states` DB access.
- **`DBConnection`** (`dbConnection.ts`) — wraps the objects and states DB servers + clients. Supports both `file` and `jsonl` DB types, and handles backup/restore used for test isolation.
- **`ControllerSetup`** / **`AdapterSetup`** — install and configure JS-Controller and the adapter into the temp test dir; `executeCommand` in `src/lib/` runs the npm/install subprocesses.

Expand Down
6 changes: 6 additions & 0 deletions build/tests/integration/lib/controllerSetup.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ export declare class ControllerSetup {
* This is necessary when switching JS-Controller versions, so no stale files and states are left behind.
*/
private clearTestDir;
/**
* Creates the contents of the package.json in the test directory
*
* @param controllerVersion The version (or dist-tag) of JS-Controller to install
*/
createTestDirPackageJson(controllerVersion: string): Record<string, any>;
prepareTestDir(controllerVersion?: string): Promise<void>;
/**
* Tests if JS-Controller is already installed
Expand Down
54 changes: 31 additions & 23 deletions build/tests/integration/lib/controllerSetup.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,26 +117,13 @@ class ControllerSetup {
await this.clearLogDir();
debug(' => done!');
}
async prepareTestDir(controllerVersion) {
const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10);
// js-controller 7.2.3 dropped support for Node.js 18 and 20. If no specific
// version was requested, and we are running on such a Node.js version, pin
// js-controller to the last version that still supports it.
if (!controllerVersion) {
controllerVersion = nodeMajorVersion <= 20 ? '7.2.2' : 'dev';
}
debug(`Preparing the test directory. JS-Controller version: "${controllerVersion}"...`);
// Make sure the test dir exists
await (0, fs_extra_1.ensureDir)(this.testDir);
// If the test directory was previously used with a different JS-Controller version,
// remove the installed files and the data directory, so no stale state is left behind
const installedControllerVersion = await this.getInstalledControllerVersion();
if (installedControllerVersion && installedControllerVersion !== controllerVersion) {
debug(`JS-Controller version changed from "${installedControllerVersion}" to "${controllerVersion}", cleaning up...`);
await this.clearTestDir();
}
// Write the package.json
const packageJson = {
/**
* Creates the contents of the package.json in the test directory
*
* @param controllerVersion The version (or dist-tag) of JS-Controller to install
*/
createTestDirPackageJson(controllerVersion) {
return {
name: path.basename(this.testDir),
version: '1.0.0',
main: 'index.js',
Expand All @@ -148,18 +135,39 @@ class ControllerSetup {
license: 'ISC',
dependencies: {
[`${this.appName}.js-controller`]: controllerVersion,
// @alcalzone/esbuild-register is needed to run TypeScript adapters
'@alcalzone/esbuild-register': '^2.5.1-1',
// tsx is needed to run TypeScript adapters
tsx: '^4.20.0',
},
// npm >= 12 blocks dependency install scripts by default:
// - js-controller needs its install script ("setup first") to create the database
// - esbuild (used by @alcalzone/esbuild-register) downloads its binary
// - esbuild (used by tsx) downloads its binary
allowScripts: {
[`${this.appName}.js-controller`]: true,
esbuild: true,
},
description: '',
};
}
async prepareTestDir(controllerVersion) {
const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10);
// js-controller 7.2.3 dropped support for Node.js 18 and 20. If no specific
// version was requested, and we are running on such a Node.js version, pin
// js-controller to the last version that still supports it.
if (!controllerVersion) {
controllerVersion = nodeMajorVersion <= 20 ? '7.2.2' : 'dev';
}
debug(`Preparing the test directory. JS-Controller version: "${controllerVersion}"...`);
// Make sure the test dir exists
await (0, fs_extra_1.ensureDir)(this.testDir);
// If the test directory was previously used with a different JS-Controller version,
// remove the installed files and the data directory, so no stale state is left behind
const installedControllerVersion = await this.getInstalledControllerVersion();
if (installedControllerVersion && installedControllerVersion !== controllerVersion) {
debug(`JS-Controller version changed from "${installedControllerVersion}" to "${controllerVersion}", cleaning up...`);
await this.clearTestDir();
}
// Write the package.json
const packageJson = this.createTestDirPackageJson(controllerVersion);
await (0, fs_extra_1.writeJSON)(path.join(this.testDir, 'package.json'), packageJson, {
spaces: 2,
});
Expand Down
4 changes: 2 additions & 2 deletions build/tests/integration/lib/harness.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,11 +221,11 @@ class TestHarness extends node_events_1.EventEmitter {
this._adapterExit = code != undefined ? code : signal;
this.emit('failed', this._adapterExit);
};
// Determine if we need to use esbuild-register for TypeScript files
// TypeScript adapters are started with tsx
const isTypeScript = mainFileAbsolute.endsWith('.ts');
const command = isWindows ? 'node.exe' : 'node';
const args = isTypeScript
? ['-r', '@alcalzone/esbuild-register', mainFileRelative, '--console']
? ['--import', 'tsx', mainFileRelative, '--console']
: [mainFileRelative, '--console'];
this._adapterProcess = (0, node_child_process_1.spawn)(command, args, {
cwd: this.testAdapterDir,
Expand Down
27 changes: 0 additions & 27 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@
"typescript": "~6.0.3"
},
"dependencies": {
"@alcalzone/esbuild-register": "^2.5.1-1",
"@types/chai": "^4.3.20",
"@types/chai-as-promised": "^7.1.8",
"@types/mocha": "^10.0.10",
Expand Down
39 changes: 39 additions & 0 deletions src/tests/integration/lib/controllerSetup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,42 @@ describe('ControllerSetup.disableDiagnosticReporting()', () => {
expect(written).to.have.length(0);
});
});

describe('ControllerSetup.createTestDirPackageJson()', () => {
let tmp: string;
let adapterDir: string;
let testDir: string;

beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'testing-pkg-'));
adapterDir = path.join(tmp, 'ioBroker.demo');
testDir = path.join(tmp, 'test');
fs.outputJSONSync(path.join(adapterDir, 'package.json'), { name: 'iobroker.demo', version: '0.0.1' });
fs.outputJSONSync(path.join(adapterDir, 'io-package.json'), { common: { name: 'demo' } });
});

afterEach(() => {
fs.removeSync(tmp);
});

it('installs the requested JS-Controller version and tsx', () => {
const packageJson = new ControllerSetup(adapterDir, testDir).createTestDirPackageJson('7.2.2');
expect(packageJson.dependencies).to.deep.equal({
'iobroker.js-controller': '7.2.2',
tsx: '^4.20.0',
});
});

it('does not need npm overrides', () => {
const packageJson = new ControllerSetup(adapterDir, testDir).createTestDirPackageJson('dev');
expect(packageJson).not.to.have.property('overrides');
});

it('allows the install scripts of js-controller and esbuild', () => {
const packageJson = new ControllerSetup(adapterDir, testDir).createTestDirPackageJson('dev');
expect(packageJson.allowScripts).to.deep.equal({
'iobroker.js-controller': true,
esbuild: true,
});
});
});
57 changes: 33 additions & 24 deletions src/tests/integration/lib/controllerSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,38 @@ export class ControllerSetup {
debug(' => done!');
}

/**
* Creates the contents of the package.json in the test directory
*
* @param controllerVersion The version (or dist-tag) of JS-Controller to install
*/
public createTestDirPackageJson(controllerVersion: string): Record<string, any> {
return {
name: path.basename(this.testDir),
version: '1.0.0',
main: 'index.js',
scripts: {
test: 'echo "Error: no test specified" && exit 1',
},
keywords: [],
author: '',
license: 'ISC',
dependencies: {
[`${this.appName}.js-controller`]: controllerVersion,
// tsx is needed to run TypeScript adapters
tsx: '^4.20.0',
},
// npm >= 12 blocks dependency install scripts by default:
// - js-controller needs its install script ("setup first") to create the database
// - esbuild (used by tsx) downloads its binary
allowScripts: {
[`${this.appName}.js-controller`]: true,
esbuild: true,
},
description: '',
};
}

public async prepareTestDir(controllerVersion?: string): Promise<void> {
const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10);

Expand All @@ -117,30 +149,7 @@ export class ControllerSetup {
}

// Write the package.json
const packageJson = {
name: path.basename(this.testDir),
version: '1.0.0',
main: 'index.js',
scripts: {
test: 'echo "Error: no test specified" && exit 1',
},
keywords: [],
author: '',
license: 'ISC',
dependencies: {
[`${this.appName}.js-controller`]: controllerVersion,
// @alcalzone/esbuild-register is needed to run TypeScript adapters
'@alcalzone/esbuild-register': '^2.5.1-1',
},
// npm >= 12 blocks dependency install scripts by default:
// - js-controller needs its install script ("setup first") to create the database
// - esbuild (used by @alcalzone/esbuild-register) downloads its binary
allowScripts: {
[`${this.appName}.js-controller`]: true,
esbuild: true,
},
description: '',
};
const packageJson = this.createTestDirPackageJson(controllerVersion);
await writeJSON(path.join(this.testDir, 'package.json'), packageJson, {
spaces: 2,
});
Expand Down
53 changes: 53 additions & 0 deletions src/tests/integration/lib/harness.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, use } from 'chai';
import chaiAsPromised from 'chai-as-promised';
import * as fs from 'fs-extra';
import childProcess from 'node:child_process';
import { EventEmitter } from 'node:events';
import * as os from 'node:os';
import * as path from 'node:path';
Expand Down Expand Up @@ -61,6 +62,58 @@ describe('TestHarness', () => {
await fs.remove(testDir);
});

describe('startAdapter()', () => {
let spawn: sinon.SinonStub;
let installedAdapterDir: string;

beforeEach(async () => {
const adapterDir = path.join(testDir, 'source');
const runtimeDir = path.join(testDir, 'runtime');
installedAdapterDir = path.join(runtimeDir, 'node_modules', 'iobroker.test-adapter');
await fs.copy(path.join(testDir, 'package.json'), path.join(adapterDir, 'package.json'));
await fs.copy(path.join(testDir, 'io-package.json'), path.join(adapterDir, 'io-package.json'));
await fs.copy(adapterDir, installedAdapterDir);
harness = new TestHarness(adapterDir, runtimeDir, dbConnection as unknown as DBConnection);

// No real process or controller is needed to verify the startup contract.
const process = Object.assign(new EventEmitter(), {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
});
spawn = sinon.stub(childProcess, 'spawn').returns(process as unknown as childProcess.ChildProcess);
});

afterEach(() => {
spawn?.restore();
});

it('starts an installed TypeScript entry with tsx and the installed adapter working directory', async () => {
const main = path.join('src', 'main.ts');
await fs.writeJson(path.join(installedAdapterDir, 'io-package.json'), {
common: { name: 'test-adapter', main },
});
await fs.outputFile(path.join(installedAdapterDir, main), 'export {};');

await harness.startAdapter();

sinon.assert.calledOnce(spawn);
expect(spawn.firstCall.args[0]).to.equal(process.platform === 'win32' ? 'node.exe' : 'node');
expect(spawn.firstCall.args[1]).to.deep.equal(['--import', 'tsx', main, '--console']);
expect(spawn.firstCall.args[2].cwd).to.equal(installedAdapterDir);
});

it('keeps JavaScript startup arguments unchanged without a TypeScript loader', async () => {
await fs.outputFile(path.join(installedAdapterDir, 'main.js'), 'module.exports = {};');

await harness.startAdapter();

sinon.assert.calledOnce(spawn);
expect(spawn.firstCall.args[0]).to.equal(process.platform === 'win32' ? 'node.exe' : 'node');
expect(spawn.firstCall.args[1]).to.deep.equal(['main.js', '--console']);
expect(spawn.firstCall.args[2].cwd).to.equal(installedAdapterDir);
});
});

describe('encryptValue() / decryptValue()', () => {
it('encrypts a value with the secret from system.config', async () => {
expect(await harness.encryptValue('s3cret!')).to.equal(encrypt(secret, 's3cret!'));
Expand Down
4 changes: 2 additions & 2 deletions src/tests/integration/lib/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,11 +225,11 @@ export class TestHarness extends EventEmitter {
this.emit('failed', this._adapterExit);
};

// Determine if we need to use esbuild-register for TypeScript files
// TypeScript adapters are started with tsx
const isTypeScript = mainFileAbsolute.endsWith('.ts');
const command = isWindows ? 'node.exe' : 'node';
const args = isTypeScript
? ['-r', '@alcalzone/esbuild-register', mainFileRelative, '--console']
? ['--import', 'tsx', mainFileRelative, '--console']
: [mainFileRelative, '--console'];

this._adapterProcess = spawn(command, args, {
Expand Down
Loading