From 629ce923fed4aa9cf8321250cb38c1aaa8ae237f Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 6 Jun 2026 22:06:20 +0200 Subject: [PATCH 01/23] wip --- package-lock.json | 275 ++++++++++++++++-- package.json | 7 +- src/app.ts | 165 +++++++++++ .../randomGeneratorVirtualDeviceLogic.ts | 8 +- .../protocol/virtual/virtualDeviceProvider.ts | 16 +- .../virtual/virtualDeviceProviderFactory.ts | 15 +- src/device/provider/deviceProvider.ts | 4 + src/device/provider/deviceProviderLoader.ts | 11 + src/index.ts | 145 +-------- .../settingsServiceProvider.ts | 27 +- tests/integration/app.spec.ts | 199 +++++++++++++ vitest.config.integration.ts | 9 + vitest.config.ts | 1 + 13 files changed, 703 insertions(+), 179 deletions(-) create mode 100644 src/app.ts create mode 100644 tests/integration/app.spec.ts create mode 100644 vitest.config.integration.ts diff --git a/package-lock.json b/package-lock.json index 5aceaf1f..ab4cc460 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "@types/cors": "^2.8.10", "@types/express": "^5.0.6", "@types/node": "^24.1.0", + "@types/supertest": "^7.2.0", "@vitest/coverage-v8": "^4.0.16", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", @@ -50,6 +51,7 @@ "jiti": "^2.6.1", "nodemon": "^3.0.2", "pino-pretty": "^10.3.0", + "supertest": "^7.2.2", "typescript": "^5.9.3", "typescript-eslint": "^8.52.0", "vitest": "^4.0.16", @@ -1876,6 +1878,19 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -1886,6 +1901,16 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "license": "MIT" @@ -1983,9 +2008,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2003,9 +2025,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2023,9 +2042,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2043,9 +2059,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2063,9 +2076,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2083,9 +2093,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2444,6 +2451,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "license": "MIT", @@ -2494,6 +2508,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.10.7", "license": "MIT", @@ -2528,6 +2549,30 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.10", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.10.tgz", + "integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/ws": { "version": "8.18.1", "license": "MIT", @@ -3095,6 +3140,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3132,6 +3184,13 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "license": "MIT", @@ -3546,6 +3605,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "6.2.1", "dev": true, @@ -3567,6 +3639,16 @@ "dev": true, "license": "MIT" }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "dev": true, @@ -3609,6 +3691,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-js": { "version": "3.47.0", "dev": true, @@ -3763,6 +3852,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "license": "MIT", @@ -3780,6 +3879,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dotenv": { "version": "17.2.3", "license": "BSD-2-Clause", @@ -4575,6 +4685,64 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "license": "MIT", @@ -5691,9 +5859,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5715,9 +5880,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5739,9 +5901,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5763,9 +5922,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5936,6 +6092,29 @@ "node": ">=18.18.0" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "license": "MIT", @@ -7671,6 +7850,42 @@ "node": ">= 6" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "dev": true, diff --git a/package.json b/package.json index e912776f..bc037c67 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@types/cors": "^2.8.10", "@types/express": "^5.0.6", "@types/node": "^24.1.0", + "@types/supertest": "^7.2.0", "@vitest/coverage-v8": "^4.0.16", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", @@ -48,6 +49,7 @@ "jiti": "^2.6.1", "nodemon": "^3.0.2", "pino-pretty": "^10.3.0", + "supertest": "^7.2.2", "typescript": "^5.9.3", "typescript-eslint": "^8.52.0", "vitest": "^4.0.16", @@ -59,8 +61,9 @@ "scripts": { "compile": "tsc && node --enable-source-maps --trace-warnings dist/index.js | pino-pretty", "dev": "nodemon", - "test": "vitest run", - "coverage": "vitest run --coverage", + "test": "vitest run --config vitest.config.ts", + "test-integration": "vitest run --config vitest.config.integration.ts", + "coverage": "vitest run --config vitest.config.ts --coverage", "typecheck": "tsc --noEmit && tsc --noEmit -p tests/tsconfig.json", "lint": "eslint -c eslint.config.ts .", "build": "tsc" diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 00000000..17595647 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,165 @@ +import cors, { CorsOptions } from 'cors'; +import contentTypeMiddleware from './middleware/contentTypeMiddleware.js'; +import express from 'express'; +import http from 'http'; +import https from 'https'; +import { Pimple } from '@timesplinter/pimple'; +import ControllerServiceProvider from './serviceProvider/controllerServiceProvider.js'; +import RepositoryServiceProvider from './serviceProvider/repositoryServiceProvider.js'; +import SerializationServiceProvider from './serviceProvider/serializationServiceProvider.js'; +import FactoryServiceProvider from './serviceProvider/factoryServiceProvider.js'; +import DeviceServiceProvider from './serviceProvider/deviceServiceProvider.js'; +import SettingsServiceProvider from './serviceProvider/settingsServiceProvider.js'; +import SchemaValidationServiceProvider from './serviceProvider/schemaValidationServiceProvider.js'; +import SocketServiceProvider from './serviceProvider/socketServiceProvider.js'; +import { DeviceUpdateData } from './socket/types.js'; +import AutomationServiceProvider from './serviceProvider/automationServiceProvider.js'; +import Device from './device/device.js'; +import WebSocketEvent from './device/webSocketEvent.js'; +import ServerServiceProvider, { SslConfig } from './serviceProvider/serverServiceProvider.js'; +import AutomationEventType from './automation/automationEventType.js'; +import LoggerServiceProvider from './serviceProvider/loggerServiceProvider.js'; +import DeviceDiscriminator from './serialization/discriminator/deviceDiscriminator.js'; +import ServiceMap from './serviceMap.js'; +import SettingsEventType from './settings/settingsEventType.js'; +import type Settings from './settings/settings.js'; +import { executeController } from './util/expressUtils.js'; +import { DeviceManagerEvent } from './device/deviceManager.js'; +import HealthServiceProvider from './serviceProvider/healthServiceProvider.js'; + +export type { SslConfig }; + +export interface AppOptions { + allowedOrigins?: string[]; + sslConfig?: SslConfig; + settingsFilePath?: string; +} + +export interface AppInstance { + expressApp: express.Application; + container: Pimple; + httpServer: http.Server; + httpsServer: https.Server | undefined; +} + +export function createApp(options: AppOptions = {}): AppInstance { + const { allowedOrigins = [], sslConfig, settingsFilePath } = options; + + const corsOptions: CorsOptions = { + origin: (origin, callback) => { + if (undefined === origin || allowedOrigins.length === 0) { + return callback(null, true); + } + + return callback(null, allowedOrigins.includes(origin)); + }, + }; + + const app = express(); + const container = new Pimple(); + + container + .register(new LoggerServiceProvider()) + .register(new HealthServiceProvider()) + .register(new ServerServiceProvider(app, corsOptions, sslConfig)) + .register(new SettingsServiceProvider(settingsFilePath)) + .register(new DeviceServiceProvider()) + .register(new ControllerServiceProvider()) + .register(new SocketServiceProvider()) + .register(new RepositoryServiceProvider()) + .register(new SerializationServiceProvider()) + .register(new AutomationServiceProvider()) + .register(new FactoryServiceProvider()) + .register(new SchemaValidationServiceProvider()) + ; + + const logger = container.get('logger.default'); + const io = container.get('server.websocket'); + const deviceManager = container.get('device.manager'); + const scriptRuntime = container.get('automation.scriptRuntime'); + const settingsManager = container.get('settings.manager'); + + // Middlewares + app + .use((req, res, next) => { + // Required for PNA preflight until https://github.com/expressjs/cors/pull/274 is merged + if (req.headers['access-control-request-private-network'] === 'true') { + res.header('Access-Control-Allow-Private-Network', 'true'); + } + + next(); + }) + .use(cors(corsOptions)) + .use(contentTypeMiddleware) + .use(express.json()) + .use(express.text()) + ; + + // Routes + app.get('/devices', executeController(container, 'controller.getDevices')); + app.get('/device/:deviceId', executeController(container, 'controller.getDevice')); + app.patch('/device/:deviceId', executeController(container, 'controller.patchDevice')); + + app.get('/automation/scripts', executeController(container, 'controller.automation.getScripts')); + app.get('/automation/scripts/:fileName', executeController(container, 'controller.automation.getScript')); + + app.post('/automation/scripts/:fileName', executeController(container, 'controller.automation.createScript')); + app.delete('/automation/scripts/:fileName', executeController(container, 'controller.automation.deleteScript')); + + app.get('/automation/log', executeController(container, 'controller.automation.getLog')); + app.post('/automation/run', executeController(container, 'controller.automation.runScript')); + app.get('/automation/stop', executeController(container, 'controller.automation.stopScript')); + app.get('/automation/status', executeController(container, 'controller.automation.statusScript')); + + app.get('/settings', executeController(container, 'controller.settings.get')); + app.put('/settings', executeController(container, 'controller.settings.put')); + + app.get('/health', executeController(container, 'controller.health')); + app.get('/version', executeController(container, 'controller.version')); + + // Whenever someone connects this gets executed + io.on('connection', socket => { + logger.debug(`Client connected: ${socket.id}`); + + socket.on('disconnect', () => { + logger.debug(`Client disconnected: ${socket.id}`); + }); + + const deviceUpdateHandler = container.get('socket.deviceUpdateHandler'); + + socket.on(WebSocketEvent.deviceUpdateReceived, (data) => deviceUpdateHandler.handle(data as DeviceUpdateData)); + }); + + const serializer = container.get('serializer.classToPlain'); + + const deviceDiscriminator = DeviceDiscriminator.createClassTransformerTypeDiscriminator('type'); + + deviceManager.on(DeviceManagerEvent.deviceConnected, (device: Device) => { + io.emit(WebSocketEvent.deviceConnected, serializer.transform(device, deviceDiscriminator)); + void scriptRuntime.runForEvent(DeviceManagerEvent.deviceConnected, device); + }); + + deviceManager.on(DeviceManagerEvent.deviceDisconnected, (device: Device) => { + io.emit(WebSocketEvent.deviceDisconnected, serializer.transform(device, deviceDiscriminator)); + void scriptRuntime.runForEvent(DeviceManagerEvent.deviceDisconnected, device); + }); + + deviceManager.on(DeviceManagerEvent.deviceRefreshed, (device: Device) => { + io.emit(WebSocketEvent.deviceRefreshed, serializer.transform(device, deviceDiscriminator)); + void scriptRuntime.runForEvent(DeviceManagerEvent.deviceRefreshed, device); + }); + + settingsManager.on(SettingsEventType.changed, (settings: Settings) => { + io.emit(SettingsEventType.changed, serializer.transform(settings)); + }); + + // Automation events + scriptRuntime.on(AutomationEventType.consoleLog, (data: unknown) => io.emit(AutomationEventType.consoleLog, data)); + + return { + expressApp: app, + container, + httpServer: container.get('server.http'), + httpsServer: container.get('server.https'), + }; +} diff --git a/src/device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceLogic.ts b/src/device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceLogic.ts index 546db33c..8a3eed2c 100644 --- a/src/device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceLogic.ts +++ b/src/device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceLogic.ts @@ -30,7 +30,13 @@ export default class RandomGeneratorVirtualDeviceLogic extends VirtualDeviceLogi public async refreshData( device: VirtualDevice ): Promise { - const newNumber = Math.floor(Math.random() * (this.config.max - this.config.min + 1)) + this.config.min; + const currentNumber = (await device.getAttribute('value'))?.value; // Just to simulate some async work + let newNumber: number; + + do { + newNumber = Math.floor(Math.random() * (this.config.max - this.config.min + 1)) + this.config.min; + } while (currentNumber !== undefined && newNumber === currentNumber); + await device.setAttribute('value', Int.from(newNumber)); } diff --git a/src/device/protocol/virtual/virtualDeviceProvider.ts b/src/device/protocol/virtual/virtualDeviceProvider.ts index 15c84e1c..730dd851 100644 --- a/src/device/protocol/virtual/virtualDeviceProvider.ts +++ b/src/device/protocol/virtual/virtualDeviceProvider.ts @@ -21,6 +21,8 @@ export default class VirtualDeviceProvider extends DeviceProvider { - // Scan for new virtual devices every 3 seconds this.discoveryInterval ??= setImmediateInterval(asyncHandler( this.discoverVirtualDevices.bind(this), (e: unknown) => this.logger.error('Error while scanning for new virtual devices', e) - ), 3000); + ), this.scanIntervalMs); + } + + public override stop(): void { + if (this.discoveryInterval !== undefined) { + clearInterval(this.discoveryInterval); + this.discoveryInterval = undefined; + } } private async discoverVirtualDevices(): Promise { diff --git a/src/device/protocol/virtual/virtualDeviceProviderFactory.ts b/src/device/protocol/virtual/virtualDeviceProviderFactory.ts index c6e23c78..b33fe481 100644 --- a/src/device/protocol/virtual/virtualDeviceProviderFactory.ts +++ b/src/device/protocol/virtual/virtualDeviceProviderFactory.ts @@ -6,8 +6,14 @@ import VirtualDeviceFactory from './virtualDeviceFactory.js'; import DeviceManager from '../../deviceManager.js'; import EventEmitterFactory from '../../../factory/eventEmitterFactory.js'; +type VirtualDeviceProviderConfig = { + scanIntervalMs: number, +} + export default class VirtualDeviceProviderFactory implements DeviceProviderFactory { + private static readonly DEFAULT_SCAN_INTERVAL_MS = 3000; + private readonly deviceManager: DeviceManager; private readonly eventEmitterFactory: EventEmitterFactory; @@ -32,13 +38,18 @@ export default class VirtualDeviceProviderFactory implements DeviceProviderFacto this.logger = logger; } - public create(): VirtualDeviceProvider { + public create(config: VirtualDeviceProviderConfig): VirtualDeviceProvider { + const scanIntervalMs = typeof config.scanIntervalMs === 'number' + ? config.scanIntervalMs + : VirtualDeviceProviderFactory.DEFAULT_SCAN_INTERVAL_MS; + return new VirtualDeviceProvider( this.deviceManager, this.eventEmitterFactory.create(), this.deviceFactory, this.settingsManager, - this.logger + this.logger, + scanIntervalMs, ); } } diff --git a/src/device/provider/deviceProvider.ts b/src/device/provider/deviceProvider.ts index 3f331388..f8bde9ad 100644 --- a/src/device/provider/deviceProvider.ts +++ b/src/device/provider/deviceProvider.ts @@ -25,4 +25,8 @@ export default abstract class DeviceProvider< public async init(): Promise { return Promise.resolve(); } + + public stop(): void { + // no-op by default + } } diff --git a/src/device/provider/deviceProviderLoader.ts b/src/device/provider/deviceProviderLoader.ts index 63cf5799..544ae6ad 100644 --- a/src/device/provider/deviceProviderLoader.ts +++ b/src/device/provider/deviceProviderLoader.ts @@ -3,6 +3,8 @@ import DeviceProviderFactory from './deviceProviderFactory.js'; import DeviceManager from '../deviceManager.js'; import Logger from '../../logging/Logger.js'; import SerialPortObserver from '../transport/serialPortObserver.js'; +import DeviceProvider from './deviceProvider.js'; +import Device from '../device.js'; export default class DeviceProviderLoader { @@ -16,6 +18,8 @@ export default class DeviceProviderLoader private readonly logger: Logger; + private startedProviders: DeviceProvider[] = []; + public constructor( deviceManager: DeviceManager, serialPortObserver: SerialPortObserver, @@ -47,6 +51,13 @@ export default class DeviceProviderLoader const provider = factory.create(deviceSource.config); await provider.init(); + this.startedProviders.push(provider); + } + } + + public stop(): void { + for (const provider of this.startedProviders) { + provider.stop(); } } } diff --git a/src/index.ts b/src/index.ts index 0b86f7f7..e5aaacd1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,33 +1,10 @@ import 'dotenv/config'; import 'reflect-metadata'; -import cors, { CorsOptions } from 'cors'; -import contentTypeMiddleware from './middleware/contentTypeMiddleware.js'; -import express from 'express'; -import { Pimple } from '@timesplinter/pimple'; -import ControllerServiceProvider from './serviceProvider/controllerServiceProvider.js'; -import RepositoryServiceProvider from './serviceProvider/repositoryServiceProvider.js'; -import SerializationServiceProvider from './serviceProvider/serializationServiceProvider.js'; -import FactoryServiceProvider from './serviceProvider/factoryServiceProvider.js'; -import DeviceServiceProvider from './serviceProvider/deviceServiceProvider.js'; -import SettingsServiceProvider from './serviceProvider/settingsServiceProvider.js'; -import SchemaValidationServiceProvider from './serviceProvider/schemaValidationServiceProvider.js'; -import SocketServiceProvider from './serviceProvider/socketServiceProvider.js'; -import { DeviceUpdateData } from './socket/types.js'; -import AutomationServiceProvider from './serviceProvider/automationServiceProvider.js'; -import Device from './device/device.js'; -import WebSocketEvent from './device/webSocketEvent.js'; -import ServerServiceProvider, { SslConfig } from './serviceProvider/serverServiceProvider.js'; -import AutomationEventType from './automation/automationEventType.js'; -import LoggerServiceProvider from './serviceProvider/loggerServiceProvider.js'; -import DeviceDiscriminator from './serialization/discriminator/deviceDiscriminator.js'; -import ServiceMap from './serviceMap.js'; -import SettingsEventType from './settings/settingsEventType.js'; -import type Settings from './settings/settings.js'; -import { executeController } from './util/expressUtils.js'; -import { DeviceManagerEvent } from './device/deviceManager.js'; +import { createApp } from './app.js'; +import { SslConfig } from './serviceProvider/serverServiceProvider.js'; import { logError } from './util/error.js'; import { setIntervalAsync } from './util/async.js'; -import HealthServiceProvider from './serviceProvider/healthServiceProvider.js'; +import WebSocketEvent from './device/webSocketEvent.js'; const APP_HTTP_PORT = process.env.PORT ?? '1337'; const APP_HTTPS_PORT = process.env.HTTPS_PORT ?? '1338'; @@ -44,126 +21,23 @@ const sslConfig: SslConfig | undefined = SSL_KEY_FILE !== undefined && SSL_CERT_ ? { keyFile: SSL_KEY_FILE, certFile: SSL_CERT_FILE } : undefined; -const corsOptions: CorsOptions = { - origin: (origin, callback) => { - if (undefined === origin || ALLOWED_ORIGINS.length === 0) { - return callback(null, true); - } - - return callback(null, ALLOWED_ORIGINS.includes(origin)); - }, -}; - -const app = express(); -const container = new Pimple(); - -container - .register(new LoggerServiceProvider()) - .register(new HealthServiceProvider()) - .register(new ServerServiceProvider(app, corsOptions, sslConfig)) - .register(new SettingsServiceProvider()) - .register(new DeviceServiceProvider()) - .register(new ControllerServiceProvider()) - .register(new SocketServiceProvider()) - .register(new RepositoryServiceProvider()) - .register(new SerializationServiceProvider()) - .register(new AutomationServiceProvider()) - .register(new FactoryServiceProvider()) - .register(new SchemaValidationServiceProvider()) -; +const { container, httpServer, httpsServer } = createApp({ + allowedOrigins: ALLOWED_ORIGINS, + sslConfig, +}); const logger = container.get('logger.default'); const io = container.get('server.websocket'); -const deviceManager = container.get('device.manager'); const serialPortObserver = container.get('device.observer.serial'); -const settingsManager = container.get('settings.manager'); -const scriptRuntime = container.get('automation.scriptRuntime'); +const healthMetricsCollector = container.get('health.metricsCollector'); container.get('device.provider.loader') .loadFromSettings() .catch(e => logError(logger, `Loading device providers failed`, e)); -// Middlewares -app - .use((req, res, next) => { - // Required for PNA preflight until https://github.com/expressjs/cors/pull/274 is merged - if (req.headers['access-control-request-private-network'] === 'true') { - res.header('Access-Control-Allow-Private-Network', 'true'); - } - - next(); - }) - .use(cors(corsOptions)) - .use(contentTypeMiddleware) - .use(express.json()) - .use(express.text()) -; - -// Routes -app.get('/devices', executeController(container, 'controller.getDevices')); -app.get('/device/:deviceId', executeController(container, 'controller.getDevice')); -app.patch('/device/:deviceId', executeController(container, 'controller.patchDevice')); - -app.get('/automation/scripts', executeController(container, 'controller.automation.getScripts')); -app.get('/automation/scripts/:fileName', executeController(container, 'controller.automation.getScript')); - -app.post('/automation/scripts/:fileName', executeController(container, 'controller.automation.createScript')); -app.delete('/automation/scripts/:fileName', executeController(container, 'controller.automation.deleteScript')); - -app.get('/automation/log', executeController(container, 'controller.automation.getLog')); -app.post('/automation/run', executeController(container, 'controller.automation.runScript')); -app.get('/automation/stop', executeController(container, 'controller.automation.stopScript')); -app.get('/automation/status', executeController(container, 'controller.automation.statusScript')); - -app.get('/settings', executeController(container, 'controller.settings.get')); -app.put('/settings', executeController(container, 'controller.settings.put')); - -app.get('/health', executeController(container, 'controller.health')); -app.get('/version', executeController(container, 'controller.version')); - -// Whenever someone connects this gets executed -io.on('connection', socket => { - logger.debug(`Client connected: ${socket.id}`); - - socket.on('disconnect', () => { - logger.debug(`Client disconnected: ${socket.id}`); - }); - - const deviceUpdateHandler = container.get('socket.deviceUpdateHandler'); - - socket.on(WebSocketEvent.deviceUpdateReceived, (data) => deviceUpdateHandler.handle(data as DeviceUpdateData)); -}); - -const serializer = container.get('serializer.classToPlain'); - -const deviceDiscriminator = DeviceDiscriminator.createClassTransformerTypeDiscriminator('type'); - void serialPortObserver.init(); -deviceManager.on(DeviceManagerEvent.deviceConnected, (device: Device) => { - io.emit(WebSocketEvent.deviceConnected, serializer.transform(device, deviceDiscriminator)); - void scriptRuntime.runForEvent(DeviceManagerEvent.deviceConnected, device); -}); - -deviceManager.on(DeviceManagerEvent.deviceDisconnected, (device: Device) => { - io.emit(WebSocketEvent.deviceDisconnected, serializer.transform(device, deviceDiscriminator)); - void scriptRuntime.runForEvent(DeviceManagerEvent.deviceDisconnected, device); -}); - -deviceManager.on(DeviceManagerEvent.deviceRefreshed, (device: Device) => { - io.emit(WebSocketEvent.deviceRefreshed, serializer.transform(device, deviceDiscriminator)); - void scriptRuntime.runForEvent(DeviceManagerEvent.deviceRefreshed, device); -}); - -settingsManager.on(SettingsEventType.changed, (settings: Settings) => { - io.emit(SettingsEventType.changed, serializer.transform(settings)); -}); - -// Automation events -scriptRuntime.on(AutomationEventType.consoleLog, (data: any) => io.emit(AutomationEventType.consoleLog, data)); - // Health metrics broadcast -const healthMetricsCollector = container.get('health.metricsCollector'); setIntervalAsync(async () => { io.emit(WebSocketEvent.healthMetrics, await healthMetricsCollector.collect()); }, { @@ -172,9 +46,6 @@ setIntervalAsync(async () => { onError: (err) => logError(logger, 'Health metrics broadcast failed', err), }); -const httpServer = container.get('server.http'); -const httpsServer = container.get('server.https'); - httpServer.listen(APP_HTTP_PORT, () => { logger.info(`Node version: ${process.version}`); logger.info(`SlvCtrl+ server listening on http://localhost:${APP_HTTP_PORT}`); diff --git a/src/serviceProvider/settingsServiceProvider.ts b/src/serviceProvider/settingsServiceProvider.ts index 0ed78a56..450c360d 100644 --- a/src/serviceProvider/settingsServiceProvider.ts +++ b/src/serviceProvider/settingsServiceProvider.ts @@ -8,6 +8,12 @@ import { fileURLToPath } from 'url'; export default class SettingsServiceProvider implements ServiceProvider { + private readonly customSettingsFilePath: string | undefined; + + public constructor(settingsFilePath?: string) { + this.customSettingsFilePath = settingsFilePath; + } + public register(container: Pimple): void { container.set('settings.schema.validator', () => { const jsonSchemaValidatorFactory = container.get('factory.validator.schema.json'); @@ -19,14 +25,27 @@ export default class SettingsServiceProvider implements ServiceProvider { - const settingsPath = `${os.homedir()}/.slvctrlplus/`; + let settingsFilePath: string; + + if (this.customSettingsFilePath !== undefined) { + settingsFilePath = this.customSettingsFilePath; + const settingsDir = path.dirname(settingsFilePath); + + if (false === fs.existsSync(settingsDir)) { + fs.mkdirSync(settingsDir, { recursive: true }); + } + } else { + const settingsPath = `${os.homedir()}/.slvctrlplus/`; + + if (false === fs.existsSync(settingsPath)) { + fs.mkdirSync(settingsPath); + } - if (false === fs.existsSync(settingsPath)) { - fs.mkdirSync(settingsPath); + settingsFilePath = `${settingsPath}settings.json`; } const settingsManager = new SettingsManager( - `${settingsPath}settings.json`, + settingsFilePath, container.get('serializer.plainToClass'), container.get('serializer.classToPlain'), container.get('settings.schema.validator'), diff --git a/tests/integration/app.spec.ts b/tests/integration/app.spec.ts new file mode 100644 index 00000000..2ab13bc4 --- /dev/null +++ b/tests/integration/app.spec.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { createApp, AppInstance } from '../../src/app.js'; +import { DeviceManagerEvent } from '../../src/device/deviceManager.js'; +import Settings from '../../src/settings/settings.js'; +import KnownDevice from '../../src/settings/knownDevice.js'; +import DeviceSource from '../../src/settings/deviceSource.js'; +import RandomGeneratorVirtualDeviceLogic from '../../src/device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceLogic.js'; +import VirtualDevice from '../../src/device/protocol/virtual/virtualDevice.js'; +import { Int } from '../../src/util/numbers.js'; + +process.env.LOG_LEVEL = process.env.LOG_LEVEL ?? 'silent'; + +const TEST_DEVICE_ID = 'a1b2c3d4-1234-4321-abcd-ef1234567890'; +const TEST_SOURCE_ID = 'b2c3d4e5-2345-4321-abcd-ef1234567891'; +const NEW_DEVICE_ID = 'c3d4e5f6-3456-4321-abcd-ef1234567892'; + +const testSettings = { + knownDevices: { + [TEST_DEVICE_ID]: { + id: TEST_DEVICE_ID, + name: 'Test Random Generator', + type: 'randomGenerator', + source: 'virtual', + config: { min: 0, max: 100 }, + }, + }, + deviceSources: { + [TEST_SOURCE_ID]: { + id: TEST_SOURCE_ID, + type: 'virtual', + config: { scanIntervalMs: 100 }, + }, + }, +}; + +describe('App integration', () => { + let instance: AppInstance; + let tmpDir: string; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'slvctrlplus-test-')); + const settingsFilePath = path.join(tmpDir, 'settings.json'); + fs.writeFileSync(settingsFilePath, JSON.stringify(testSettings)); + + instance = createApp({ settingsFilePath }); + + const deviceManager = instance.container.get('device.manager'); + const deviceConnected = new Promise(resolve => { + deviceManager.on(DeviceManagerEvent.deviceConnected, () => resolve()); + }); + + await instance.container.get('device.provider.loader').loadFromSettings(); + await deviceConnected; + }); + + afterEach(async () => { + instance.container.get('device.provider.loader').stop(); + + for (const device of instance.container.get('device.manager').getConnectedDevices()) { + await device.close(); + } + + await new Promise(resolve => instance.httpServer.close(() => resolve())); + + fs.rmSync(tmpDir, { recursive: true }); + }); + + it('responds to devices endpoint', async () => { + const res = await request(instance.expressApp).get('/devices'); + + expect(res.status).toBe(200); + }); + + it('virtual device connected', () => { + const devices = instance.container.get('device.manager').getConnectedDevices(); + + expect(devices).toHaveLength(1); + expect(devices[0].getDeviceId).toBe(TEST_DEVICE_ID); + }); + + it('virtual device gets refreshed', async () => { + const deviceManager = instance.container.get('device.manager'); + const device = deviceManager.getConnectedDevices()[0] as VirtualDevice; + + const firstValue = (await device.getAttribute('value'))?.value; + + const secondValue = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for device refresh')), 2000); + + instance.container.get('device.manager').on(DeviceManagerEvent.deviceRefreshed, async () => { + clearTimeout(timeout); + resolve((await device.getAttribute('value'))?.value); + }); + }); + + expect(firstValue).toBeDefined(); + expect(secondValue).toBeDefined(); + expect(secondValue).not.toBe(firstValue); + }); + + it('dynamically detects a new virtual device added to settings', async () => { + const deviceManager = instance.container.get('device.manager'); + const settingsManager = instance.container.get('settings.manager'); + + expect(deviceManager.getConnectedDevices()).toHaveLength(1); + + + const newSettings = new Settings(); + newSettings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', {})); + newSettings.addKnownDevice(new KnownDevice(TEST_DEVICE_ID, TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 })); + newSettings.addKnownDevice(new KnownDevice(NEW_DEVICE_ID, NEW_DEVICE_ID, 'Test Device 2', 'randomGenerator', 'virtual', { min: 0, max: 50 })); + + const newDeviceConnected = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for new device to connect')), 1000); + + deviceManager.on(DeviceManagerEvent.deviceConnected, (device) => { + if (device.getDeviceId === NEW_DEVICE_ID) { + clearTimeout(timeout); + resolve(); + } + }); + }); + + settingsManager.replace(newSettings); + + await newDeviceConnected; + + const devices = deviceManager.getConnectedDevices(); + + expect(devices).toHaveLength(2); + expect(devices[0].getDeviceId).toBe(TEST_DEVICE_ID); + expect(devices[1].getDeviceId).toBe(NEW_DEVICE_ID); + }, 2000); + + it('dynamically removes a virtual device deleted from settings', async () => { + const deviceManager = instance.container.get('device.manager'); + const settingsManager = instance.container.get('settings.manager'); + + const settingsWithTwoDevices = new Settings(); + settingsWithTwoDevices.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', {})); + settingsWithTwoDevices.addKnownDevice(new KnownDevice(TEST_DEVICE_ID, TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 })); + settingsWithTwoDevices.addKnownDevice(new KnownDevice(NEW_DEVICE_ID, NEW_DEVICE_ID, 'Test Device 2', 'randomGenerator', 'virtual', { min: 0, max: 50 })); + + const newDeviceConnected = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for new device to connect')), 1000); + + deviceManager.on(DeviceManagerEvent.deviceConnected, (device) => { + if (device.getDeviceId === NEW_DEVICE_ID) { + clearTimeout(timeout); + resolve(); + } + }); + }); + + settingsManager.replace(settingsWithTwoDevices); + + await newDeviceConnected; + + const settingsWithOneDevice = new Settings(); + settingsWithOneDevice.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', {})); + settingsWithOneDevice.addKnownDevice(new KnownDevice(TEST_DEVICE_ID, TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 })); + + const newDeviceDisconnected = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for device to disconnect')), 1000); + + deviceManager.on(DeviceManagerEvent.deviceDisconnected, (device) => { + if (device.getDeviceId === NEW_DEVICE_ID) { + clearTimeout(timeout); + resolve(); + } + }); + }); + + settingsManager.replace(settingsWithOneDevice); + + await newDeviceDisconnected; + }, 4000); + + it('virtual device disconnected', async () => { + const deviceManager = instance.container.get('device.manager'); + const device = deviceManager.getConnectedDevices()[0]; + + const disconnected = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for device disconnection')), 2000); + + deviceManager.on(DeviceManagerEvent.deviceDisconnected, () => { + clearTimeout(timeout); + resolve(); + }); + }); + + await device.close(); + await disconnected; + }); +}); diff --git a/vitest.config.integration.ts b/vitest.config.integration.ts new file mode 100644 index 00000000..573ee3e2 --- /dev/null +++ b/vitest.config.integration.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + setupFiles: ["./tests/vitest.setup.ts"], + include: ["tests/integration/**/*.{test,spec}.?(c|m)[jt]s?(x)"], + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 96a3abbd..39d06980 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { environment: "node", setupFiles: ["./tests/vitest.setup.ts"], + exclude: ["**/node_modules/**", "**/tests/integration/**"], coverage: { provider: "v8", reporter: ["text", "html", "lcov"], From d635904b2281742a9189110b2af25bcb8545a1e6 Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 6 Jun 2026 22:07:59 +0200 Subject: [PATCH 02/23] Run integrations tests in pipeline --- .github/workflows/test.yml | 3 +++ package.json | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8e3b4baf..3a7c5e5c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,6 +49,9 @@ jobs: - name: Coverage run: | npm run coverage + - name: Integration Tests + run: | + npm run test:integration # Summary job that all matrix jobs must pass # Used as the required status check in branch protection diff --git a/package.json b/package.json index bc037c67..18a7785d 100644 --- a/package.json +++ b/package.json @@ -61,8 +61,8 @@ "scripts": { "compile": "tsc && node --enable-source-maps --trace-warnings dist/index.js | pino-pretty", "dev": "nodemon", - "test": "vitest run --config vitest.config.ts", - "test-integration": "vitest run --config vitest.config.integration.ts", + "test:unit": "vitest run --config vitest.config.ts", + "test:integration": "vitest run --config vitest.config.integration.ts", "coverage": "vitest run --config vitest.config.ts --coverage", "typecheck": "tsc --noEmit && tsc --noEmit -p tests/tsconfig.json", "lint": "eslint -c eslint.config.ts .", From f0136743576478a36124a701f143806b199f01cb Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 6 Jun 2026 22:10:51 +0200 Subject: [PATCH 03/23] Ignore new vitest file --- eslint.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/eslint.config.ts b/eslint.config.ts index 99822f31..fe61030f 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -12,6 +12,7 @@ export default [ 'coverage', 'tests', 'vitest.config.ts', + 'vitest.config.integration.ts', 'eslint.config.ts', ], }, From 3cd3cd723e9429cdec3b2cd6a155287ef3c7ed12 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 7 Jun 2026 13:45:33 +0200 Subject: [PATCH 04/23] wip --- .env.example | 4 +- .github/workflows/test.yml | 4 +- src/app.ts | 171 ++++++++------ src/automation/scriptRuntime.ts | 6 + src/device/deviceManager.ts | 16 ++ .../virtual/virtualDeviceProviderFactory.ts | 2 +- src/env.ts | 23 ++ src/index.ts | 56 +---- tests/integration/api.spec.ts | 51 ++++ tests/integration/app.spec.ts | 199 ---------------- tests/integration/automationScripts.spec.ts | 220 ++++++++++++++++++ tests/integration/deviceEvents.spec.ts | 138 +++++++++++ tests/integration/helpers/appHelper.ts | 132 +++++++++++ vitest.config.integration.ts | 1 + 14 files changed, 710 insertions(+), 313 deletions(-) create mode 100644 src/env.ts create mode 100644 tests/integration/api.spec.ts delete mode 100644 tests/integration/app.spec.ts create mode 100644 tests/integration/automationScripts.spec.ts create mode 100644 tests/integration/deviceEvents.spec.ts create mode 100644 tests/integration/helpers/appHelper.ts diff --git a/.env.example b/.env.example index d47b3ac6..e60223a4 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,6 @@ PORT=1337 LOG_LEVEL=debug ALLOWED_ORIGINS= APP_VERSION=dev -# SSL_KEY=/path/to/server.key -# SSL_CERT=/path/to/server.crt +# SSL_KEY_FILE=/path/to/server.key +# SSL_CERT_FILE=/path/to/server.crt # HTTPS_PORT=1338 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3a7c5e5c..58f13091 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,10 +46,10 @@ jobs: - name: Typecheck run: | npm run typecheck - - name: Coverage + - name: Unit tests with coverage run: | npm run coverage - - name: Integration Tests + - name: Integration tests run: | npm run test:integration diff --git a/src/app.ts b/src/app.ts index 17595647..09ff6e51 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,8 +1,6 @@ import cors, { CorsOptions } from 'cors'; import contentTypeMiddleware from './middleware/contentTypeMiddleware.js'; import express from 'express'; -import http from 'http'; -import https from 'https'; import { Pimple } from '@timesplinter/pimple'; import ControllerServiceProvider from './serviceProvider/controllerServiceProvider.js'; import RepositoryServiceProvider from './serviceProvider/repositoryServiceProvider.js'; @@ -26,6 +24,8 @@ import type Settings from './settings/settings.js'; import { executeController } from './util/expressUtils.js'; import { DeviceManagerEvent } from './device/deviceManager.js'; import HealthServiceProvider from './serviceProvider/healthServiceProvider.js'; +import { setIntervalAsync } from './util/async.js'; +import { logError } from './util/error.js'; export type { SslConfig }; @@ -38,64 +38,10 @@ export interface AppOptions { export interface AppInstance { expressApp: express.Application; container: Pimple; - httpServer: http.Server; - httpsServer: https.Server | undefined; + listen: (httpPort: number, httpsPort?: number) => void; } -export function createApp(options: AppOptions = {}): AppInstance { - const { allowedOrigins = [], sslConfig, settingsFilePath } = options; - - const corsOptions: CorsOptions = { - origin: (origin, callback) => { - if (undefined === origin || allowedOrigins.length === 0) { - return callback(null, true); - } - - return callback(null, allowedOrigins.includes(origin)); - }, - }; - - const app = express(); - const container = new Pimple(); - - container - .register(new LoggerServiceProvider()) - .register(new HealthServiceProvider()) - .register(new ServerServiceProvider(app, corsOptions, sslConfig)) - .register(new SettingsServiceProvider(settingsFilePath)) - .register(new DeviceServiceProvider()) - .register(new ControllerServiceProvider()) - .register(new SocketServiceProvider()) - .register(new RepositoryServiceProvider()) - .register(new SerializationServiceProvider()) - .register(new AutomationServiceProvider()) - .register(new FactoryServiceProvider()) - .register(new SchemaValidationServiceProvider()) - ; - - const logger = container.get('logger.default'); - const io = container.get('server.websocket'); - const deviceManager = container.get('device.manager'); - const scriptRuntime = container.get('automation.scriptRuntime'); - const settingsManager = container.get('settings.manager'); - - // Middlewares - app - .use((req, res, next) => { - // Required for PNA preflight until https://github.com/expressjs/cors/pull/274 is merged - if (req.headers['access-control-request-private-network'] === 'true') { - res.header('Access-Control-Allow-Private-Network', 'true'); - } - - next(); - }) - .use(cors(corsOptions)) - .use(contentTypeMiddleware) - .use(express.json()) - .use(express.text()) - ; - - // Routes +const configureRoutes = (app: express.Application, container: Pimple): void => { app.get('/devices', executeController(container, 'controller.getDevices')); app.get('/device/:deviceId', executeController(container, 'controller.getDevice')); app.patch('/device/:deviceId', executeController(container, 'controller.patchDevice')); @@ -116,6 +62,27 @@ export function createApp(options: AppOptions = {}): AppInstance { app.get('/health', executeController(container, 'controller.health')); app.get('/version', executeController(container, 'controller.version')); +} + +const configureWebsocket = (container: Pimple): void => { + const deviceManager = container.get('device.manager'); + const scriptRuntime = container.get('automation.scriptRuntime'); + const settingsManager = container.get('settings.manager'); + const serializer = container.get('serializer.classToPlain'); + const logger = container.get('logger.default'); + const io = container.get('server.websocket'); + const healthMetricsCollector = container.get('health.metricsCollector'); + + const deviceDiscriminator = DeviceDiscriminator.createClassTransformerTypeDiscriminator('type'); + + // Health metrics broadcast + setIntervalAsync(async () => { + io.emit(WebSocketEvent.healthMetrics, await healthMetricsCollector.collect()); + }, { + intervalMs: 500, + timeoutMs: 2_000, + onError: (err) => logError(logger, 'Health metrics broadcast failed', err), + }); // Whenever someone connects this gets executed io.on('connection', socket => { @@ -130,10 +97,6 @@ export function createApp(options: AppOptions = {}): AppInstance { socket.on(WebSocketEvent.deviceUpdateReceived, (data) => deviceUpdateHandler.handle(data as DeviceUpdateData)); }); - const serializer = container.get('serializer.classToPlain'); - - const deviceDiscriminator = DeviceDiscriminator.createClassTransformerTypeDiscriminator('type'); - deviceManager.on(DeviceManagerEvent.deviceConnected, (device: Device) => { io.emit(WebSocketEvent.deviceConnected, serializer.transform(device, deviceDiscriminator)); void scriptRuntime.runForEvent(DeviceManagerEvent.deviceConnected, device); @@ -155,11 +118,89 @@ export function createApp(options: AppOptions = {}): AppInstance { // Automation events scriptRuntime.on(AutomationEventType.consoleLog, (data: unknown) => io.emit(AutomationEventType.consoleLog, data)); +}; + +const loadDeviceProviders = (container: Pimple): void => { + const serialPortObserver = container.get('device.observer.serial'); + const logger = container.get('logger.default'); + + container.get('device.provider.loader') + .loadFromSettings() + .catch(e => logError(logger, `Loading device providers failed`, e)); + + serialPortObserver.init().catch(e => logError(logger, `Initializing serial port observer failed`, e)); +}; + +export const createApp = (options: AppOptions = {}): AppInstance => { + const { allowedOrigins = [], sslConfig, settingsFilePath } = options; + + const corsOptions: CorsOptions = { + origin: (origin, callback) => { + if (undefined === origin || allowedOrigins.length === 0) { + return callback(null, true); + } + + return callback(null, allowedOrigins.includes(origin)); + }, + }; + + const app = express(); + const container = new Pimple(); + + container + .register(new LoggerServiceProvider()) + .register(new HealthServiceProvider()) + .register(new ServerServiceProvider(app, corsOptions, sslConfig)) + .register(new SettingsServiceProvider(settingsFilePath)) + .register(new DeviceServiceProvider()) + .register(new ControllerServiceProvider()) + .register(new SocketServiceProvider()) + .register(new RepositoryServiceProvider()) + .register(new SerializationServiceProvider()) + .register(new AutomationServiceProvider()) + .register(new FactoryServiceProvider()) + .register(new SchemaValidationServiceProvider()) + ; + + // Middlewares + app + .use((req, res, next) => { + // Required for PNA preflight until https://github.com/expressjs/cors/pull/274 is merged + if (req.headers['access-control-request-private-network'] === 'true') { + res.header('Access-Control-Allow-Private-Network', 'true'); + } + + next(); + }) + .use(cors(corsOptions)) + .use(contentTypeMiddleware) + .use(express.json()) + .use(express.text()) + ; + + // Routes + configureRoutes(app, container); + configureWebsocket(container); + loadDeviceProviders(container); return { expressApp: app, container, - httpServer: container.get('server.http'), - httpsServer: container.get('server.https'), + listen: (httpPort: number, httpsPort?: number): void => { + const logger = container.get('logger.default'); + const httpServer = container.get('server.http'); + const httpsServer = container.get('server.https'); + + httpServer.listen(httpPort, () => { + logger.info(`Node version: ${process.version}`); + logger.info(`SlvCtrl+ server listening on http://localhost:${httpPort}`); + }); + + if (httpsServer !== undefined && httpsPort !== undefined) { + httpsServer.listen(httpsPort, () => { + logger.info(`SlvCtrl+ server listening on https://localhost:${httpsPort} (ssl)`); + }); + } + } }; -} +} \ No newline at end of file diff --git a/src/automation/scriptRuntime.ts b/src/automation/scriptRuntime.ts index 23d7b504..839391e8 100644 --- a/src/automation/scriptRuntime.ts +++ b/src/automation/scriptRuntime.ts @@ -417,6 +417,12 @@ export class ScriptRuntime this.eventEmitter.on(event, listener); return this; } + + public off (event: E, listener: ScriptRuntimeEvents[E]): this + { + this.eventEmitter.off(event, listener); + return this; + } } export default ScriptRuntime; diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index e2a9f96a..206b3817 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -149,6 +149,22 @@ export default class DeviceManager this.eventEmitter.on(event, listener); } + public off( + event: T, + listener: (...args: DeviceManagerEventMap[T]) => void + ): void + { + this.eventEmitter.off(event, listener); + } + + public async reset(): Promise + { + for (const device of Array.from(this.connectedDevices.values())) { + await device.close(); + } + this.detectedDeviceAcquireQueue.clear(); + } + private clearDetectedDeviceAcquireQueue(deviceId: string, reason: string): void { for (const entry of this.detectedDeviceAcquireQueue.get(deviceId) ?? []) { diff --git a/src/device/protocol/virtual/virtualDeviceProviderFactory.ts b/src/device/protocol/virtual/virtualDeviceProviderFactory.ts index b33fe481..a3e6a419 100644 --- a/src/device/protocol/virtual/virtualDeviceProviderFactory.ts +++ b/src/device/protocol/virtual/virtualDeviceProviderFactory.ts @@ -39,7 +39,7 @@ export default class VirtualDeviceProviderFactory implements DeviceProviderFacto } public create(config: VirtualDeviceProviderConfig): VirtualDeviceProvider { - const scanIntervalMs = typeof config.scanIntervalMs === 'number' + const scanIntervalMs = typeof config.scanIntervalMs === 'number' && config.scanIntervalMs > 0 ? config.scanIntervalMs : VirtualDeviceProviderFactory.DEFAULT_SCAN_INTERVAL_MS; diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 00000000..c5bafe97 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,23 @@ +import { Value } from '@sinclair/typebox/value'; +import { Static, Type } from '@sinclair/typebox'; + +const EnvSchema = Type.Object({ + PORT: Type.Number({ default: 1337, minimum: 1, maximum: 65535 }), + HTTPS_PORT: Type.Number({ default: 1338, minimum: 1, maximum: 65535 }), + SSL_CERT_FILE: Type.Optional(Type.String()), + SSL_KEY_FILE: Type.Optional(Type.String()), + ALLOWED_ORIGINS: Type.Optional(Type.String()), +}); + +type Env = Static; + +export const parseEnv = (env: NodeJS.ProcessEnv): Env => { + const converted = Value.Convert(EnvSchema, { ...env }); + + if (!Value.Check(EnvSchema, converted)) { + const errors = [...Value.Errors(EnvSchema, converted)]; + throw new Error(`Invalid environment variables:\n${errors.map(e => ` ${e.path}: ${e.message}`).join('\n')}`); + } + + return Value.Decode(EnvSchema, converted); +}; diff --git a/src/index.ts b/src/index.ts index e5aaacd1..74d07f9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,61 +2,29 @@ import 'dotenv/config'; import 'reflect-metadata'; import { createApp } from './app.js'; import { SslConfig } from './serviceProvider/serverServiceProvider.js'; -import { logError } from './util/error.js'; -import { setIntervalAsync } from './util/async.js'; -import WebSocketEvent from './device/webSocketEvent.js'; +import { parseEnv } from './env.js'; -const APP_HTTP_PORT = process.env.PORT ?? '1337'; -const APP_HTTPS_PORT = process.env.HTTPS_PORT ?? '1338'; -const ALLOWED_ORIGINS = undefined !== process.env.ALLOWED_ORIGINS && null !== process.env.ALLOWED_ORIGINS.length - ? process.env.ALLOWED_ORIGINS.split(',') +const env = parseEnv(process.env); + +const allowedOrigins = undefined !== env.ALLOWED_ORIGINS && null !== env.ALLOWED_ORIGINS.length + ? env.ALLOWED_ORIGINS.split(',') .map(origin => origin.trim()) .filter(origin => origin.length > 0) : []; -const SSL_KEY_FILE = process.env.SSL_KEY; -const SSL_CERT_FILE = process.env.SSL_CERT; - -const sslConfig: SslConfig | undefined = SSL_KEY_FILE !== undefined && SSL_CERT_FILE !== undefined - ? { keyFile: SSL_KEY_FILE, certFile: SSL_CERT_FILE } +const sslConfig: SslConfig | undefined = env.SSL_KEY_FILE !== undefined && env.SSL_CERT_FILE !== undefined + ? { keyFile: env.SSL_KEY_FILE, certFile: env.SSL_CERT_FILE } : undefined; -const { container, httpServer, httpsServer } = createApp({ - allowedOrigins: ALLOWED_ORIGINS, +const app = createApp({ + allowedOrigins, sslConfig, }); -const logger = container.get('logger.default'); -const io = container.get('server.websocket'); -const serialPortObserver = container.get('device.observer.serial'); -const healthMetricsCollector = container.get('health.metricsCollector'); - -container.get('device.provider.loader') - .loadFromSettings() - .catch(e => logError(logger, `Loading device providers failed`, e)); - -void serialPortObserver.init(); - -// Health metrics broadcast -setIntervalAsync(async () => { - io.emit(WebSocketEvent.healthMetrics, await healthMetricsCollector.collect()); -}, { - intervalMs: 500, - timeoutMs: 2_000, - onError: (err) => logError(logger, 'Health metrics broadcast failed', err), -}); - -httpServer.listen(APP_HTTP_PORT, () => { - logger.info(`Node version: ${process.version}`); - logger.info(`SlvCtrl+ server listening on http://localhost:${APP_HTTP_PORT}`); -}); - -if (httpsServer !== undefined) { - httpsServer.listen(APP_HTTPS_PORT, () => { - logger.info(`SlvCtrl+ server listening on https://localhost:${APP_HTTPS_PORT} (ssl)`); - }); -} +const logger = app.container.get('logger.default'); process.on('uncaughtException', (error: Error) => { logger.error('Asynchronous error caught', error); }); + +app.listen(env.PORT, env.HTTPS_PORT); diff --git a/tests/integration/api.spec.ts b/tests/integration/api.spec.ts new file mode 100644 index 00000000..2873e508 --- /dev/null +++ b/tests/integration/api.spec.ts @@ -0,0 +1,51 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import { AppInstance } from '../../src/app.js'; +import { TEST_DEVICE_ID, createTestApp, teardownTestApp, resetTestApp, connectDevices } from './helpers/appHelper.js'; + +describe('REST API', () => { + let instance: AppInstance; + let tmpDir: string; + + beforeAll(async () => { + ({ instance, tmpDir } = await createTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(instance, tmpDir); + }); + + beforeEach(async () => { + await resetTestApp(instance); + }); + + it('GET /devices returns connected devices', async () => { + const testDeviceName = 'Test Random Generator'; + + await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: testDeviceName }]); + + const res = await request(instance.expressApp).get('/devices'); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(1); + expect(res.body.items[0]).toEqual(expect.objectContaining({ + deviceId: TEST_DEVICE_ID, + deviceName: testDeviceName, + provider: 'virtual', + deviceModel: 'randomGenerator', + fwVersion: '1.0.0', + state: 'READY', + type: 'virtual', + controllable: false, + config: { min: 0, max: 100 }, + attributes: { + value: expect.objectContaining({ + name: 'value', + label: 'Random number', + modifier: 'ro', + type: 'int', + }), + }, + })); + }); +}); diff --git a/tests/integration/app.spec.ts b/tests/integration/app.spec.ts deleted file mode 100644 index 2ab13bc4..00000000 --- a/tests/integration/app.spec.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import request from 'supertest'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import { createApp, AppInstance } from '../../src/app.js'; -import { DeviceManagerEvent } from '../../src/device/deviceManager.js'; -import Settings from '../../src/settings/settings.js'; -import KnownDevice from '../../src/settings/knownDevice.js'; -import DeviceSource from '../../src/settings/deviceSource.js'; -import RandomGeneratorVirtualDeviceLogic from '../../src/device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceLogic.js'; -import VirtualDevice from '../../src/device/protocol/virtual/virtualDevice.js'; -import { Int } from '../../src/util/numbers.js'; - -process.env.LOG_LEVEL = process.env.LOG_LEVEL ?? 'silent'; - -const TEST_DEVICE_ID = 'a1b2c3d4-1234-4321-abcd-ef1234567890'; -const TEST_SOURCE_ID = 'b2c3d4e5-2345-4321-abcd-ef1234567891'; -const NEW_DEVICE_ID = 'c3d4e5f6-3456-4321-abcd-ef1234567892'; - -const testSettings = { - knownDevices: { - [TEST_DEVICE_ID]: { - id: TEST_DEVICE_ID, - name: 'Test Random Generator', - type: 'randomGenerator', - source: 'virtual', - config: { min: 0, max: 100 }, - }, - }, - deviceSources: { - [TEST_SOURCE_ID]: { - id: TEST_SOURCE_ID, - type: 'virtual', - config: { scanIntervalMs: 100 }, - }, - }, -}; - -describe('App integration', () => { - let instance: AppInstance; - let tmpDir: string; - - beforeEach(async () => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'slvctrlplus-test-')); - const settingsFilePath = path.join(tmpDir, 'settings.json'); - fs.writeFileSync(settingsFilePath, JSON.stringify(testSettings)); - - instance = createApp({ settingsFilePath }); - - const deviceManager = instance.container.get('device.manager'); - const deviceConnected = new Promise(resolve => { - deviceManager.on(DeviceManagerEvent.deviceConnected, () => resolve()); - }); - - await instance.container.get('device.provider.loader').loadFromSettings(); - await deviceConnected; - }); - - afterEach(async () => { - instance.container.get('device.provider.loader').stop(); - - for (const device of instance.container.get('device.manager').getConnectedDevices()) { - await device.close(); - } - - await new Promise(resolve => instance.httpServer.close(() => resolve())); - - fs.rmSync(tmpDir, { recursive: true }); - }); - - it('responds to devices endpoint', async () => { - const res = await request(instance.expressApp).get('/devices'); - - expect(res.status).toBe(200); - }); - - it('virtual device connected', () => { - const devices = instance.container.get('device.manager').getConnectedDevices(); - - expect(devices).toHaveLength(1); - expect(devices[0].getDeviceId).toBe(TEST_DEVICE_ID); - }); - - it('virtual device gets refreshed', async () => { - const deviceManager = instance.container.get('device.manager'); - const device = deviceManager.getConnectedDevices()[0] as VirtualDevice; - - const firstValue = (await device.getAttribute('value'))?.value; - - const secondValue = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('Timed out waiting for device refresh')), 2000); - - instance.container.get('device.manager').on(DeviceManagerEvent.deviceRefreshed, async () => { - clearTimeout(timeout); - resolve((await device.getAttribute('value'))?.value); - }); - }); - - expect(firstValue).toBeDefined(); - expect(secondValue).toBeDefined(); - expect(secondValue).not.toBe(firstValue); - }); - - it('dynamically detects a new virtual device added to settings', async () => { - const deviceManager = instance.container.get('device.manager'); - const settingsManager = instance.container.get('settings.manager'); - - expect(deviceManager.getConnectedDevices()).toHaveLength(1); - - - const newSettings = new Settings(); - newSettings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', {})); - newSettings.addKnownDevice(new KnownDevice(TEST_DEVICE_ID, TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 })); - newSettings.addKnownDevice(new KnownDevice(NEW_DEVICE_ID, NEW_DEVICE_ID, 'Test Device 2', 'randomGenerator', 'virtual', { min: 0, max: 50 })); - - const newDeviceConnected = new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('Timed out waiting for new device to connect')), 1000); - - deviceManager.on(DeviceManagerEvent.deviceConnected, (device) => { - if (device.getDeviceId === NEW_DEVICE_ID) { - clearTimeout(timeout); - resolve(); - } - }); - }); - - settingsManager.replace(newSettings); - - await newDeviceConnected; - - const devices = deviceManager.getConnectedDevices(); - - expect(devices).toHaveLength(2); - expect(devices[0].getDeviceId).toBe(TEST_DEVICE_ID); - expect(devices[1].getDeviceId).toBe(NEW_DEVICE_ID); - }, 2000); - - it('dynamically removes a virtual device deleted from settings', async () => { - const deviceManager = instance.container.get('device.manager'); - const settingsManager = instance.container.get('settings.manager'); - - const settingsWithTwoDevices = new Settings(); - settingsWithTwoDevices.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', {})); - settingsWithTwoDevices.addKnownDevice(new KnownDevice(TEST_DEVICE_ID, TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 })); - settingsWithTwoDevices.addKnownDevice(new KnownDevice(NEW_DEVICE_ID, NEW_DEVICE_ID, 'Test Device 2', 'randomGenerator', 'virtual', { min: 0, max: 50 })); - - const newDeviceConnected = new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('Timed out waiting for new device to connect')), 1000); - - deviceManager.on(DeviceManagerEvent.deviceConnected, (device) => { - if (device.getDeviceId === NEW_DEVICE_ID) { - clearTimeout(timeout); - resolve(); - } - }); - }); - - settingsManager.replace(settingsWithTwoDevices); - - await newDeviceConnected; - - const settingsWithOneDevice = new Settings(); - settingsWithOneDevice.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', {})); - settingsWithOneDevice.addKnownDevice(new KnownDevice(TEST_DEVICE_ID, TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 })); - - const newDeviceDisconnected = new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('Timed out waiting for device to disconnect')), 1000); - - deviceManager.on(DeviceManagerEvent.deviceDisconnected, (device) => { - if (device.getDeviceId === NEW_DEVICE_ID) { - clearTimeout(timeout); - resolve(); - } - }); - }); - - settingsManager.replace(settingsWithOneDevice); - - await newDeviceDisconnected; - }, 4000); - - it('virtual device disconnected', async () => { - const deviceManager = instance.container.get('device.manager'); - const device = deviceManager.getConnectedDevices()[0]; - - const disconnected = new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('Timed out waiting for device disconnection')), 2000); - - deviceManager.on(DeviceManagerEvent.deviceDisconnected, () => { - clearTimeout(timeout); - resolve(); - }); - }); - - await device.close(); - await disconnected; - }); -}); diff --git a/tests/integration/automationScripts.spec.ts b/tests/integration/automationScripts.spec.ts new file mode 100644 index 00000000..815cd1a2 --- /dev/null +++ b/tests/integration/automationScripts.spec.ts @@ -0,0 +1,220 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import { AppInstance } from '../../src/app.js'; +import { ScriptRuntime } from '../../src/automation/scriptRuntime.js'; +import AutomationEventType from '../../src/automation/automationEventType.js'; +import { + TEST_DEVICE_ID, + createTestApp, + teardownTestApp, + resetTestApp, + connectDevices, +} from './helpers/appHelper.js'; + +function collectUntilMarker( + scriptRuntime: ScriptRuntime, + marker: string, + timeoutMs = 2000, +): Promise { + return new Promise((resolve, reject) => { + const collected: string[] = []; + + const listener = (msg: string) => { + collected.push(msg); + if (msg === marker) { + scriptRuntime.off(AutomationEventType.consoleLog, listener); + clearTimeout(timeout); + resolve(collected); + } + }; + + const timeout = setTimeout(() => { + scriptRuntime.off(AutomationEventType.consoleLog, listener); + reject(new Error(`Timed out waiting for marker "${marker}". Got: ${JSON.stringify(collected)}`)); + }, timeoutMs); + + scriptRuntime.on(AutomationEventType.consoleLog, listener); + }); +} + +describe('Automation scripts', () => { + let instance: AppInstance; + let tmpDir: string; + + beforeAll(async () => { + ({ instance, tmpDir } = await createTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(instance, tmpDir); + }); + + beforeEach(async () => { + await resetTestApp(instance); + }); + + describe('Script lifecycle via REST API', () => { + it('POST /automation/run loads the script and reports running status', async () => { + const scriptRuntime = instance.container.get('automation.scriptRuntime'); + + const scriptStarted = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for scriptStarted')), 2000); + scriptRuntime.on(AutomationEventType.scriptStarted, () => { + clearTimeout(timeout); + resolve(); + }); + }); + + const res = await request(instance.expressApp) + .post('/automation/run') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + expect(res.status).toBe(200); + expect(res.body.running).toBe(true); + expect(res.body.runningSince).toBeDefined(); + + await scriptStarted; + + const statusRes = await request(instance.expressApp).get('/automation/status'); + expect(statusRes.status).toBe(200); + expect(statusRes.body.running).toBe(true); + }); + + it('POST /automation/run returns 400 for non-text/plain content type', async () => { + const res = await request(instance.expressApp) + .post('/automation/run') + .set('Content-Type', 'application/json') + .send(JSON.stringify({ script: 'onEvent(() => {});' })); + + expect(res.status).toBe(400); + }); + + it('GET /automation/stop stops the running script and emits scriptStopped', async () => { + const scriptRuntime = instance.container.get('automation.scriptRuntime'); + + await request(instance.expressApp) + .post('/automation/run') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + const scriptStopped = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for scriptStopped')), 2000); + scriptRuntime.on(AutomationEventType.scriptStopped, () => { + clearTimeout(timeout); + resolve(); + }); + }); + + const stopRes = await request(instance.expressApp).get('/automation/stop'); + expect(stopRes.status).toBe(200); + + await scriptStopped; + + const statusRes = await request(instance.expressApp).get('/automation/status'); + expect(statusRes.status).toBe(200); + expect(statusRes.body.running).toBe(false); + }); + + it('GET /automation/status returns not running when no script is loaded', async () => { + const res = await request(instance.expressApp).get('/automation/status'); + + expect(res.status).toBe(200); + expect(res.body.running).toBe(false); + expect(res.body.runningSince).toBeNull(); + }); + }); + + describe('Script reacts to device events', () => { + it('onEvent is called with deviceConnected when a real device connects', async () => { + const scriptRuntime = instance.container.get('automation.scriptRuntime'); + const MARKER = 'connect-done'; + + const logsPromise = collectUntilMarker(scriptRuntime, MARKER); + + await scriptRuntime.load(` + onEvent(async (event) => { + if (event.type !== 'deviceConnected') return; + console.log(event.device.getDeviceId); + console.log(event.device.getDeviceName); + console.log('${MARKER}'); + }); + `); + + await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const logs = await logsPromise; + + expect(logs).toContain(TEST_DEVICE_ID); + expect(logs).toContain('Test Random Generator'); + }); + + it('onEvent receives the real attribute value on deviceRefreshed', async () => { + const scriptRuntime = instance.container.get('automation.scriptRuntime'); + const MARKER = 'refresh-done'; + + await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + await scriptRuntime.load(` + onEvent(async (event) => { + if (event.type !== 'deviceRefreshed') return; + const attr = await event.device.getAttribute('value'); + console.log(attr !== undefined ? String(attr.value) : 'no-value'); + console.log('${MARKER}'); + }); + `); + + const logs = await collectUntilMarker(scriptRuntime, MARKER, 3000); + + const valueLog = logs.find(l => l !== MARKER)!; + expect(valueLog).not.toBe('no-value'); + expect(Number(valueLog)).toBeGreaterThanOrEqual(0); + expect(Number(valueLog)).toBeLessThanOrEqual(100); + }, 5000); + + it('onEvent is called with deviceDisconnected when a real device disconnects', async () => { + const scriptRuntime = instance.container.get('automation.scriptRuntime'); + const MARKER = 'disconnect-done'; + + await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const logsPromise = collectUntilMarker(scriptRuntime, MARKER); + + await scriptRuntime.load(` + onEvent(async (event) => { + if (event.type !== 'deviceDisconnected') return; + console.log(event.device.getDeviceId); + console.log('${MARKER}'); + }); + `); + + const device = instance.container.get('device.manager').getConnectedDevices()[0]; + await device.close(); + + const logs = await logsPromise; + expect(logs).toContain(TEST_DEVICE_ID); + }); + + it('onStart runs once when script is loaded via API, before any device events', async () => { + const scriptRuntime = instance.container.get('automation.scriptRuntime'); + const MARKER = 'start-done'; + + const logsPromise = collectUntilMarker(scriptRuntime, MARKER); + + await request(instance.expressApp) + .post('/automation/run') + .set('Content-Type', 'text/plain') + .send(` + onStart(async () => { + console.log('init'); + console.log('${MARKER}'); + }); + onEvent(() => {}); + `); + + const logs = await logsPromise; + expect(logs).toContain('init'); + expect(logs.indexOf('init')).toBeLessThan(logs.indexOf(MARKER)); + }); + }); +}); diff --git a/tests/integration/deviceEvents.spec.ts b/tests/integration/deviceEvents.spec.ts new file mode 100644 index 00000000..901a3a12 --- /dev/null +++ b/tests/integration/deviceEvents.spec.ts @@ -0,0 +1,138 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { AppInstance } from '../../src/app.js'; +import { DeviceManagerEvent } from '../../src/device/deviceManager.js'; +import Settings from '../../src/settings/settings.js'; +import KnownDevice from '../../src/settings/knownDevice.js'; +import DeviceSource from '../../src/settings/deviceSource.js'; +import RandomGeneratorVirtualDeviceLogic from '../../src/device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceLogic.js'; +import VirtualDevice from '../../src/device/protocol/virtual/virtualDevice.js'; +import { + TEST_DEVICE_ID, + TEST_SOURCE_ID, + NEW_DEVICE_ID, + createTestApp, + teardownTestApp, + resetTestApp, + connectDevices, +} from './helpers/appHelper.js'; + +describe('Device events', () => { + let instance: AppInstance; + let tmpDir: string; + + beforeAll(async () => { + ({ instance, tmpDir } = await createTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(instance, tmpDir); + }); + + beforeEach(async () => { + await resetTestApp(instance); + }); + + it('virtual device connected', async () => { + await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const devices = instance.container.get('device.manager').getConnectedDevices(); + + expect(devices).toHaveLength(1); + expect(devices[0].getDeviceId).toBe(TEST_DEVICE_ID); + }); + + it('virtual device gets refreshed', async () => { + await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const deviceManager = instance.container.get('device.manager'); + const device = deviceManager.getConnectedDevices()[0] as VirtualDevice; + + let observedValue: number | undefined; + let changedValue: number | undefined; + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for device value to change')), 1000); + + deviceManager.on(DeviceManagerEvent.deviceRefreshed, async () => { + const value = (await device.getAttribute('value'))?.value; + + if (undefined === observedValue) { + observedValue = value; + return; + } + + if (value !== observedValue) { + changedValue = value; + clearTimeout(timeout); + resolve(); + } + }); + }); + + expect(observedValue).toBeDefined(); + expect(changedValue).toBeDefined(); + }); + + it('dynamically detects a new virtual device added to settings', async () => { + await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const deviceManager = instance.container.get('device.manager'); + + expect(deviceManager.getConnectedDevices()).toHaveLength(1); + + await connectDevices(instance, [{ id: NEW_DEVICE_ID, name: 'Test Device 2', config: { min: 0, max: 50 } }]); + + const devices = deviceManager.getConnectedDevices(); + + expect(devices).toHaveLength(2); + expect(devices[0].getDeviceId).toBe(TEST_DEVICE_ID); + expect(devices[1].getDeviceId).toBe(NEW_DEVICE_ID); + }, 1000); + + it('dynamically removes a virtual device deleted from settings', async () => { + await connectDevices(instance, [ + { id: TEST_DEVICE_ID, name: 'Test Random Generator' }, + { id: NEW_DEVICE_ID, name: 'Test Device 2', config: { min: 0, max: 50 } }, + ]); + + const deviceManager = instance.container.get('device.manager'); + const settingsManager = instance.container.get('settings.manager'); + + const deviceDisconnected = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for device to disconnect')), 1000); + + deviceManager.on(DeviceManagerEvent.deviceDisconnected, (device) => { + if (device.getDeviceId === NEW_DEVICE_ID) { + clearTimeout(timeout); + resolve(); + } + }); + }); + + const settingsWithOneDevice = new Settings(); + settingsWithOneDevice.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', {})); + settingsWithOneDevice.addKnownDevice(new KnownDevice(TEST_DEVICE_ID, TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 })); + settingsManager.replace(settingsWithOneDevice); + + await deviceDisconnected; + }, 1000); + + it('virtual device disconnected', async () => { + await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const deviceManager = instance.container.get('device.manager'); + const device = deviceManager.getConnectedDevices()[0]; + + const disconnected = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for device disconnection')), 2000); + + deviceManager.on(DeviceManagerEvent.deviceDisconnected, () => { + clearTimeout(timeout); + resolve(); + }); + }); + + await device.close(); + await disconnected; + }); +}); diff --git a/tests/integration/helpers/appHelper.ts b/tests/integration/helpers/appHelper.ts new file mode 100644 index 00000000..3cad2921 --- /dev/null +++ b/tests/integration/helpers/appHelper.ts @@ -0,0 +1,132 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { createApp, AppInstance } from '../../../src/app.js'; +import { DeviceManagerEvent } from '../../../src/device/deviceManager.js'; +import KnownDevice from '../../../src/settings/knownDevice.js'; +import Settings from '../../../src/settings/settings.js'; +import DeviceSource from '../../../src/settings/deviceSource.js'; + +process.env.LOG_LEVEL = process.env.LOG_LEVEL ?? 'silent'; + +export const TEST_DEVICE_ID = 'a1b2c3d4-1234-4321-abcd-ef1234567890'; +export const TEST_SOURCE_ID = 'b2c3d4e5-2345-4321-abcd-ef1234567891'; +export const NEW_DEVICE_ID = 'c3d4e5f6-3456-4321-abcd-ef1234567892'; + +export type DeviceSpec = { id: string, name: string, config?: { min: number, max: number } }; + +const baseSettings = { + knownDevices: {}, + deviceSources: { + [TEST_SOURCE_ID]: { + id: TEST_SOURCE_ID, + type: 'virtual', + config: { scanIntervalMs: 50 }, + }, + }, +}; + +function makeBaseSettings(): Settings { + const settings = new Settings(); + settings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', { scanIntervalMs: 50 })); + return settings; +} + +export const createTestApp = async (): Promise<{ instance: AppInstance, tmpDir: string }> => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'slvctrlplus-test-')); + const settingsFilePath = path.join(tmpDir, 'settings.json'); + fs.writeFileSync(settingsFilePath, JSON.stringify(baseSettings)); + + const instance = createApp({ settingsFilePath }); + + await instance.container.get('device.provider.loader').loadFromSettings(); + + return { instance, tmpDir }; +}; + +export const teardownTestApp = async (instance: AppInstance, tmpDir: string): Promise => { + const scriptRuntime = instance.container.get('automation.scriptRuntime'); + if (scriptRuntime.isRunning()) { + await scriptRuntime.stop(); + } + + instance.container.get('device.provider.loader').stop(); + + await instance.container.get('device.manager').reset(); + + await new Promise(resolve => instance.container.get('server.http').close(() => resolve())); + + fs.rmSync(tmpDir, { recursive: true }); +}; + +export const resetTestApp = async (instance: AppInstance): Promise => { + const deviceManager = instance.container.get('device.manager'); + const scriptRuntime = instance.container.get('automation.scriptRuntime'); + const settingsManager = instance.container.get('settings.manager'); + + if (scriptRuntime.isRunning()) { + await scriptRuntime.stop(); + } + + const connectedIds = deviceManager.getConnectedDevices().map(d => d.getDeviceId); + + if (connectedIds.length > 0) { + // Drive disconnection through settings so VirtualDeviceProvider clears its + // own attemptedDevices/connectedDevices maps via its normal discovery loop. + // Calling device.close() directly would leave those maps stale. + const remaining = new Set(connectedIds); + const allGone = new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`resetTestApp: ${[...remaining].join(', ')} did not disconnect within 1s`)), + 1000, + ); + const listener = (device: { getDeviceId: string }) => { + remaining.delete(device.getDeviceId); + if (remaining.size === 0) { + deviceManager.off(DeviceManagerEvent.deviceDisconnected, listener); + clearTimeout(timeout); + resolve(); + } + }; + deviceManager.on(DeviceManagerEvent.deviceDisconnected, listener); + }); + + settingsManager.replace(makeBaseSettings()); + await allGone; + } else { + settingsManager.replace(makeBaseSettings()); + } +}; + +export const connectDevices = (instance: AppInstance, specs: DeviceSpec[]): Promise => { + const deviceManager = instance.container.get('device.manager'); + const settingsManager = instance.container.get('settings.manager'); + + const pendingIds = new Set(specs.map(s => s.id)); + + const allConnected = new Promise((resolve, reject) => { + const listener = (device: { getDeviceId: string }) => { + pendingIds.delete(device.getDeviceId); + if (pendingIds.size === 0) { + deviceManager.off(DeviceManagerEvent.deviceConnected, listener); + clearTimeout(timeout); + resolve(); + } + }; + + const timeout = setTimeout(() => { + deviceManager.off(DeviceManagerEvent.deviceConnected, listener); + reject(new Error(`Timed out waiting for devices to connect: ${[...pendingIds].join(', ')}`)); + }, 1000); + + deviceManager.on(DeviceManagerEvent.deviceConnected, listener); + }); + + const settings = settingsManager.load(); + for (const spec of specs) { + settings.addKnownDevice(new KnownDevice(spec.id, spec.id, spec.name, 'randomGenerator', 'virtual', spec.config ?? { min: 0, max: 100 })); + } + settingsManager.replace(settings); + + return allConnected; +}; diff --git a/vitest.config.integration.ts b/vitest.config.integration.ts index 573ee3e2..53ca38c5 100644 --- a/vitest.config.integration.ts +++ b/vitest.config.integration.ts @@ -5,5 +5,6 @@ export default defineConfig({ environment: "node", setupFiles: ["./tests/vitest.setup.ts"], include: ["tests/integration/**/*.{test,spec}.?(c|m)[jt]s?(x)"], + fileParallelism: false, }, }); From 3ed2431c5d944b3bc4ad968deb6807268e4c5a96 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 7 Jun 2026 23:03:25 +0200 Subject: [PATCH 05/23] Make integration tests with shared app instance work --- src/automation/scriptRuntime.ts | 32 +++++++++++++++++++-- tests/integration/automationScripts.spec.ts | 14 +++++++-- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/automation/scriptRuntime.ts b/src/automation/scriptRuntime.ts index 839391e8..83363856 100644 --- a/src/automation/scriptRuntime.ts +++ b/src/automation/scriptRuntime.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'crypto'; import ivm from 'isolated-vm'; import { transform } from 'sucrase'; import Device from '../device/device.js'; @@ -170,6 +171,8 @@ export class ScriptRuntime private pendingLifecycleDone: ((errMsg: string | null) => void) | null = null; + private currentRunId: string | null = null; + private readonly deviceRepository: DeviceRepositoryInterface; private readonly logPath: string; @@ -184,6 +187,8 @@ export class ScriptRuntime private processingQueue = false; + private processQueuePromise: Promise | null = null; + public constructor(deviceRepository: DeviceRepositoryInterface, logPath: string, eventEmitter: EventEmitter, logger: Logger) { this.eventEmitter = eventEmitter; this.deviceRepository = deviceRepository; @@ -193,6 +198,9 @@ export class ScriptRuntime public async load(scriptCode: string): Promise { + const loadId = randomUUID(); + this.currentRunId = loadId; + this.isolate = new ivm.Isolate({ memoryLimit: 128 }); this.vmContext = await this.isolate.createContext(); @@ -244,6 +252,7 @@ export class ScriptRuntime })); await jail.set('__done', new ivm.Callback((errMsg: string | null) => { + if (this.currentRunId !== loadId) return; if (this.pendingEventDone !== null) { const done = this.pendingEventDone; this.pendingEventDone = null; @@ -252,6 +261,7 @@ export class ScriptRuntime }, { async: true })); await jail.set('__lifecycleDone', new ivm.Callback((errMsg: string | null) => { + if (this.currentRunId !== loadId) return; if (this.pendingLifecycleDone !== null) { const done = this.pendingLifecycleDone; this.pendingLifecycleDone = null; @@ -296,8 +306,23 @@ export class ScriptRuntime public async stop(): Promise { + // Null dispatchRef first so runForEvent() returns early for any events + // arriving during teardown, and queued-but-not-started tasks resolve immediately. + this.dispatchRef = null; + + // Unblock any in-flight event handler so processQueue() can exit its await + if (this.pendingEventDone !== null) { + const done = this.pendingEventDone; + this.pendingEventDone = null; + done('script stopped'); + } this.eventQueue = []; - this.processingQueue = false; + + // Wait for the processQueue coroutine to finish before tearing down the isolate + if (this.processQueuePromise !== null) { + await this.processQueuePromise; + this.processQueuePromise = null; + } const lifecycleRef = this.lifecycleRef; if (lifecycleRef !== null) { @@ -314,7 +339,7 @@ export class ScriptRuntime } } - this.dispatchRef = null; + this.currentRunId = null; this.lifecycleRef = null; this.pendingEventDone = null; this.pendingLifecycleDone = null; @@ -367,7 +392,7 @@ export class ScriptRuntime })); if (!this.processingQueue) { - void this.processQueue(); + this.processQueuePromise = this.processQueue(); } } @@ -388,6 +413,7 @@ export class ScriptRuntime } this.processingQueue = false; + this.processQueuePromise = null; } public async getLog(maxLines: number): Promise diff --git a/tests/integration/automationScripts.spec.ts b/tests/integration/automationScripts.spec.ts index 815cd1a2..f68a7efa 100644 --- a/tests/integration/automationScripts.spec.ts +++ b/tests/integration/automationScripts.spec.ts @@ -51,7 +51,7 @@ describe('Automation scripts', () => { beforeEach(async () => { await resetTestApp(instance); - }); + }, 2000); describe('Script lifecycle via REST API', () => { it('POST /automation/run loads the script and reports running status', async () => { @@ -82,12 +82,16 @@ describe('Automation scripts', () => { }); it('POST /automation/run returns 400 for non-text/plain content type', async () => { + const scriptRuntime = instance.container.get('automation.scriptRuntime'); + const res = await request(instance.expressApp) .post('/automation/run') .set('Content-Type', 'application/json') .send(JSON.stringify({ script: 'onEvent(() => {});' })); expect(res.status).toBe(400); + + await scriptRuntime.stop(); }); it('GET /automation/stop stops the running script and emits scriptStopped', async () => { @@ -145,6 +149,8 @@ describe('Automation scripts', () => { const logs = await logsPromise; + await scriptRuntime.stop(); + expect(logs).toContain(TEST_DEVICE_ID); expect(logs).toContain('Test Random Generator'); }); @@ -164,13 +170,17 @@ describe('Automation scripts', () => { }); `); + // collectUntilMarker is called after load() so its timeout only counts + // event-wait time, not isolate-creation/compilation time. const logs = await collectUntilMarker(scriptRuntime, MARKER, 3000); + await scriptRuntime.stop(); + const valueLog = logs.find(l => l !== MARKER)!; expect(valueLog).not.toBe('no-value'); expect(Number(valueLog)).toBeGreaterThanOrEqual(0); expect(Number(valueLog)).toBeLessThanOrEqual(100); - }, 5000); + }); it('onEvent is called with deviceDisconnected when a real device disconnects', async () => { const scriptRuntime = instance.container.get('automation.scriptRuntime'); From f837eb2bc305444c43036c082dd91e50a5a94111 Mon Sep 17 00:00:00 2001 From: HRS Date: Mon, 8 Jun 2026 06:46:16 +0200 Subject: [PATCH 06/23] Remove unnecessary checks that have been added --- src/automation/scriptRuntime.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/automation/scriptRuntime.ts b/src/automation/scriptRuntime.ts index 83363856..ee49e845 100644 --- a/src/automation/scriptRuntime.ts +++ b/src/automation/scriptRuntime.ts @@ -1,4 +1,3 @@ -import { randomUUID } from 'crypto'; import ivm from 'isolated-vm'; import { transform } from 'sucrase'; import Device from '../device/device.js'; @@ -171,8 +170,6 @@ export class ScriptRuntime private pendingLifecycleDone: ((errMsg: string | null) => void) | null = null; - private currentRunId: string | null = null; - private readonly deviceRepository: DeviceRepositoryInterface; private readonly logPath: string; @@ -198,9 +195,6 @@ export class ScriptRuntime public async load(scriptCode: string): Promise { - const loadId = randomUUID(); - this.currentRunId = loadId; - this.isolate = new ivm.Isolate({ memoryLimit: 128 }); this.vmContext = await this.isolate.createContext(); @@ -252,7 +246,6 @@ export class ScriptRuntime })); await jail.set('__done', new ivm.Callback((errMsg: string | null) => { - if (this.currentRunId !== loadId) return; if (this.pendingEventDone !== null) { const done = this.pendingEventDone; this.pendingEventDone = null; @@ -261,7 +254,6 @@ export class ScriptRuntime }, { async: true })); await jail.set('__lifecycleDone', new ivm.Callback((errMsg: string | null) => { - if (this.currentRunId !== loadId) return; if (this.pendingLifecycleDone !== null) { const done = this.pendingLifecycleDone; this.pendingLifecycleDone = null; @@ -339,7 +331,6 @@ export class ScriptRuntime } } - this.currentRunId = null; this.lifecycleRef = null; this.pendingEventDone = null; this.pendingLifecycleDone = null; From a3cdf0ecf58b8d5f546c18015faaf3113a202ba5 Mon Sep 17 00:00:00 2001 From: HRS Date: Mon, 8 Jun 2026 06:53:07 +0200 Subject: [PATCH 07/23] Add some more simplifications --- CLAUDE.md | 86 +++++++++++++++++++++++++++++++ CONTEXT.md | 89 +++++++++++++++++++++++++++++++++ src/automation/scriptRuntime.ts | 11 +--- 3 files changed, 177 insertions(+), 9 deletions(-) create mode 100644 CLAUDE.md create mode 100644 CONTEXT.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..12effc8f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Development (watch mode with auto-restart) +npm run dev + +# Build +npm run build + +# Run tests +npm test + +# Run a single test file +npx vitest run tests/unit/controller/getDevicesController.spec.ts + +# Test coverage +npm run coverage + +# Lint +npm run lint + +# Type check +npm run typecheck +``` + +The server runs on `http://localhost:1337` by default (configurable via `PORT` env var). + +## Rules for contributors + +TypeScript: +- Never use type assertions (i.e. `as`), always type things properly - If there's no way around it: ask +- Never use `any` as a type, always type things properly - If there's no way around it: ask + +Git: +- A git branch name has to follow the pattern `^(feat|chore|fix)/(.+)$` +- If you create a new git branch: ask for the branch name + +## Architecture + +**SlvCtrl+ Server** is a device control server that manages connected hardware devices (serial, BLE, virtual) and exposes a REST + WebSocket API for controlling them and running automation scripts. + +### Dependency Injection + +The app uses [Pimple](https://github.com/timesplinter/pimple) for DI. All services are registered in `src/serviceMap.ts` (the type map) and in service providers under `src/serviceProvider/`. The container is assembled and started in `src/index.ts`. + +### Device Layer (`src/device/`) + +Devices are the core abstraction. Each device has: +- A **transport** (how data is sent/received): `serial/` or `ble/` +- A **protocol** (how messages are structured): `slvctrlplus/`, `buttplug/`, `zc95/`, `estim2b/`, `airotic/`, `virtual/` + +**Device lifecycle**: +1. A transport observer (`SerialPortObserver`, `BleObserver`) detects hardware +2. Protocol-specific device factories (`IDeviceProvider`) create the right device instance +3. `DeviceManager` tracks all connected devices and emits events (`DeviceManagerEvent`) +4. Events trigger WebSocket broadcasts and automation scripts + +The `Device` base class (`src/device/device.ts`) defines the common interface. Each protocol subdirectory contains its own device class, attribute definitions, and connection logic. + +### API Layer (`src/controller/`) + +Express v5 REST controllers handle device operations (`GET /devices`, `PATCH /device/:id`), automation (`POST /automation/script/:id/run`), and settings. Socket.IO (`src/socket/`) streams real-time events to clients. + +### Automation Engine (`src/automation/`) + +Scripts run in a sandboxed `vm2` environment (`scriptRuntime.ts`). They can be triggered by device events (connect/disconnect/refresh) or called via API. Console output is streamed via WebSocket. + +### Serialization (`src/serialization/`) + +Uses `class-transformer` with `@Expose`/`@Exclude` decorators. Polymorphic device types use a discriminator field for correct deserialization. Validators use `ajv` JSON Schema. + +## Environment Variables + +Copy `.env.example` to `.env`: +- `PORT` – server port (default: `1337`) +- `LOG_LEVEL` – pino log level (default: `debug`) +- `ALLOWED_ORIGINS` – comma-separated CORS origins +- `APP_VERSION` – version string (default: `dev`) + +## Testing + +Tests live in `tests/unit/` and use Vitest with `vitest-mock-extended` for mocking. The setup file is `tests/vitest.setup.ts`. CI runs tests on Node 20, 22, and 24 across Linux/macOS/Windows. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..5b5f3c5a --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,89 @@ +# SlvCtrl+ Server + +A local device control hub that connects to hardware over serial and BLE, exposes devices via a REST and WebSocket API, and lets automation scripts react to device events. + +## Language + +### Actors + +**Operator**: +The person who owns the hardware and runs the server. +_Avoid_: user, admin + +**Client**: +Any consumer of the server API — typically a frontend UI. +_Avoid_: consumer, app, user + +### Device Layer + +**Device**: +A connected hardware or virtual unit managed by the server. +_Avoid_: hardware (virtual devices exist too) + +**Transport**: +The physical or logical channel over which raw bytes are exchanged with a Device. Two concrete transports exist: serial port and BLE UART. BLE Transports support transparent auto-reconnect at the peripheral level; serial Transports do not (a lost serial port means the Device is gone). +_Avoid_: connection, channel + +**Protocol**: +The message structure and encoding used to communicate with a Device over a Transport (e.g. slvctrlplus, zc95, estim2b, buttplug, airotic). +_Avoid_: driver, format + +**Virtual Device**: +A software-only Device with no physical hardware counterpart that implements device-shaped logic (e.g. random number generator, text-to-speech). +_Avoid_: simulated device, fake device + +**Device Attribute**: +A named, typed value representing one observable or controllable property of a Device, with a read/write modifier (`ro`, `rw`, `wo`). +_Avoid_: property, field, channel + +### Device Lifecycle + +**Device Manager**: +The central registry of connected Devices. It owns the device lifecycle and emits lifecycle events (detected, claimed, connected, disconnected). +_Avoid_: registry, hub, event bus + +**Device Provider**: +A protocol-specific component that recognises detected hardware it understands, claims it, and creates the corresponding Device. A Device Provider handles exactly one transport type (serial or BLE); it ignores detections from the other transport. +_Avoid_: factory, driver + +**Observer**: +A transport-specific component that continuously scans for hardware and announces newly detected devices to the Device Manager. Two concrete observers exist: Serial Port Observer (polls every 3 s) and BLE Observer (scans continuously for BLE UART peripherals). +_Avoid_: scanner, listener, watcher + +### Automation + +**Automation Script**: +A user-defined program definition that can be started and stopped explicitly via the API. +_Avoid_: script, job, task + +**Script Runtime**: +The active execution context of a running Automation Script. While running, it reacts to Device lifecycle events and can read and write Device Attributes. +_Avoid_: instance, process, execution + +## Relationships + +- A **Device** has exactly one **Transport** and exactly one **Protocol**; the **Device** owns its **Transport**'s lifecycle +- A **Device** exposes one or more **Device Attributes** +- A **Device Provider** is responsible for exactly one **Protocol** and exactly one transport type +- An **Observer** is responsible for exactly one transport type and announces detected hardware to the **Device Manager** +- The **Device Manager** tracks all connected **Devices** and emits lifecycle events +- An **Automation Script** has at most one active **Script Runtime** at a time +- A **Script Runtime** subscribes to **Device Manager** lifecycle events + +## API boundary + +- REST is used by the **Client** to build initial state and perform control operations (update settings, start/stop **Script Runtimes**). +- WebSocket streams real-time **Device Attribute** updates and **Device** lifecycle events to the **Client**, and is used to write **Device Attributes**. + +## Example dialogue + +> **Dev:** "When a device is plugged in, who creates it?" +> **Domain expert:** "The **Device Manager** announces a detected device. Each **Device Provider** that understands the hardware can try to claim it — first one wins. The claiming **Device Provider** then creates the **Device** and registers it with the **Device Manager**." + +> **Dev:** "Can an **Automation Script** start itself when a device connects?" +> **Domain expert:** "No — an **Automation Script** must be explicitly started by the **Operator** via the API. Once its **Script Runtime** is active, it can react to device connect and disconnect events." + +## Flagged ambiguities + +- "script" was used loosely to mean both the definition and the running instance — resolved: **Automation Script** (definition) vs **Script Runtime** (active execution). +- "connection" was used to mean both the physical channel and the protocol handshake — resolved: **Transport** (channel) vs the Device Provider's connect flow (handshake). diff --git a/src/automation/scriptRuntime.ts b/src/automation/scriptRuntime.ts index ee49e845..68527061 100644 --- a/src/automation/scriptRuntime.ts +++ b/src/automation/scriptRuntime.ts @@ -154,7 +154,7 @@ var __dispatchEvent = function(eventType, deviceId, deviceName) { }; `; -export class ScriptRuntime +export default class ScriptRuntime { private readonly eventEmitter: EventEmitter; @@ -182,8 +182,6 @@ export class ScriptRuntime private eventQueue: (() => Promise)[] = []; - private processingQueue = false; - private processQueuePromise: Promise | null = null; public constructor(deviceRepository: DeviceRepositoryInterface, logPath: string, eventEmitter: EventEmitter, logger: Logger) { @@ -382,15 +380,13 @@ export class ScriptRuntime ); })); - if (!this.processingQueue) { + if (this.processQueuePromise === null) { this.processQueuePromise = this.processQueue(); } } private async processQueue(): Promise { - this.processingQueue = true; - while (this.eventQueue.length > 0) { const task = this.eventQueue.shift()!; try { @@ -403,7 +399,6 @@ export class ScriptRuntime } } - this.processingQueue = false; this.processQueuePromise = null; } @@ -441,5 +436,3 @@ export class ScriptRuntime return this; } } - -export default ScriptRuntime; From 4376ed7408f05660022a725d75c18fa60ac8dcf0 Mon Sep 17 00:00:00 2001 From: HRS Date: Mon, 8 Jun 2026 06:53:33 +0200 Subject: [PATCH 08/23] Remove files again --- CLAUDE.md | 86 ---------------------------------------------------- CONTEXT.md | 89 ------------------------------------------------------ 2 files changed, 175 deletions(-) delete mode 100644 CLAUDE.md delete mode 100644 CONTEXT.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 12effc8f..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,86 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Commands - -```bash -# Development (watch mode with auto-restart) -npm run dev - -# Build -npm run build - -# Run tests -npm test - -# Run a single test file -npx vitest run tests/unit/controller/getDevicesController.spec.ts - -# Test coverage -npm run coverage - -# Lint -npm run lint - -# Type check -npm run typecheck -``` - -The server runs on `http://localhost:1337` by default (configurable via `PORT` env var). - -## Rules for contributors - -TypeScript: -- Never use type assertions (i.e. `as`), always type things properly - If there's no way around it: ask -- Never use `any` as a type, always type things properly - If there's no way around it: ask - -Git: -- A git branch name has to follow the pattern `^(feat|chore|fix)/(.+)$` -- If you create a new git branch: ask for the branch name - -## Architecture - -**SlvCtrl+ Server** is a device control server that manages connected hardware devices (serial, BLE, virtual) and exposes a REST + WebSocket API for controlling them and running automation scripts. - -### Dependency Injection - -The app uses [Pimple](https://github.com/timesplinter/pimple) for DI. All services are registered in `src/serviceMap.ts` (the type map) and in service providers under `src/serviceProvider/`. The container is assembled and started in `src/index.ts`. - -### Device Layer (`src/device/`) - -Devices are the core abstraction. Each device has: -- A **transport** (how data is sent/received): `serial/` or `ble/` -- A **protocol** (how messages are structured): `slvctrlplus/`, `buttplug/`, `zc95/`, `estim2b/`, `airotic/`, `virtual/` - -**Device lifecycle**: -1. A transport observer (`SerialPortObserver`, `BleObserver`) detects hardware -2. Protocol-specific device factories (`IDeviceProvider`) create the right device instance -3. `DeviceManager` tracks all connected devices and emits events (`DeviceManagerEvent`) -4. Events trigger WebSocket broadcasts and automation scripts - -The `Device` base class (`src/device/device.ts`) defines the common interface. Each protocol subdirectory contains its own device class, attribute definitions, and connection logic. - -### API Layer (`src/controller/`) - -Express v5 REST controllers handle device operations (`GET /devices`, `PATCH /device/:id`), automation (`POST /automation/script/:id/run`), and settings. Socket.IO (`src/socket/`) streams real-time events to clients. - -### Automation Engine (`src/automation/`) - -Scripts run in a sandboxed `vm2` environment (`scriptRuntime.ts`). They can be triggered by device events (connect/disconnect/refresh) or called via API. Console output is streamed via WebSocket. - -### Serialization (`src/serialization/`) - -Uses `class-transformer` with `@Expose`/`@Exclude` decorators. Polymorphic device types use a discriminator field for correct deserialization. Validators use `ajv` JSON Schema. - -## Environment Variables - -Copy `.env.example` to `.env`: -- `PORT` – server port (default: `1337`) -- `LOG_LEVEL` – pino log level (default: `debug`) -- `ALLOWED_ORIGINS` – comma-separated CORS origins -- `APP_VERSION` – version string (default: `dev`) - -## Testing - -Tests live in `tests/unit/` and use Vitest with `vitest-mock-extended` for mocking. The setup file is `tests/vitest.setup.ts`. CI runs tests on Node 20, 22, and 24 across Linux/macOS/Windows. diff --git a/CONTEXT.md b/CONTEXT.md deleted file mode 100644 index 5b5f3c5a..00000000 --- a/CONTEXT.md +++ /dev/null @@ -1,89 +0,0 @@ -# SlvCtrl+ Server - -A local device control hub that connects to hardware over serial and BLE, exposes devices via a REST and WebSocket API, and lets automation scripts react to device events. - -## Language - -### Actors - -**Operator**: -The person who owns the hardware and runs the server. -_Avoid_: user, admin - -**Client**: -Any consumer of the server API — typically a frontend UI. -_Avoid_: consumer, app, user - -### Device Layer - -**Device**: -A connected hardware or virtual unit managed by the server. -_Avoid_: hardware (virtual devices exist too) - -**Transport**: -The physical or logical channel over which raw bytes are exchanged with a Device. Two concrete transports exist: serial port and BLE UART. BLE Transports support transparent auto-reconnect at the peripheral level; serial Transports do not (a lost serial port means the Device is gone). -_Avoid_: connection, channel - -**Protocol**: -The message structure and encoding used to communicate with a Device over a Transport (e.g. slvctrlplus, zc95, estim2b, buttplug, airotic). -_Avoid_: driver, format - -**Virtual Device**: -A software-only Device with no physical hardware counterpart that implements device-shaped logic (e.g. random number generator, text-to-speech). -_Avoid_: simulated device, fake device - -**Device Attribute**: -A named, typed value representing one observable or controllable property of a Device, with a read/write modifier (`ro`, `rw`, `wo`). -_Avoid_: property, field, channel - -### Device Lifecycle - -**Device Manager**: -The central registry of connected Devices. It owns the device lifecycle and emits lifecycle events (detected, claimed, connected, disconnected). -_Avoid_: registry, hub, event bus - -**Device Provider**: -A protocol-specific component that recognises detected hardware it understands, claims it, and creates the corresponding Device. A Device Provider handles exactly one transport type (serial or BLE); it ignores detections from the other transport. -_Avoid_: factory, driver - -**Observer**: -A transport-specific component that continuously scans for hardware and announces newly detected devices to the Device Manager. Two concrete observers exist: Serial Port Observer (polls every 3 s) and BLE Observer (scans continuously for BLE UART peripherals). -_Avoid_: scanner, listener, watcher - -### Automation - -**Automation Script**: -A user-defined program definition that can be started and stopped explicitly via the API. -_Avoid_: script, job, task - -**Script Runtime**: -The active execution context of a running Automation Script. While running, it reacts to Device lifecycle events and can read and write Device Attributes. -_Avoid_: instance, process, execution - -## Relationships - -- A **Device** has exactly one **Transport** and exactly one **Protocol**; the **Device** owns its **Transport**'s lifecycle -- A **Device** exposes one or more **Device Attributes** -- A **Device Provider** is responsible for exactly one **Protocol** and exactly one transport type -- An **Observer** is responsible for exactly one transport type and announces detected hardware to the **Device Manager** -- The **Device Manager** tracks all connected **Devices** and emits lifecycle events -- An **Automation Script** has at most one active **Script Runtime** at a time -- A **Script Runtime** subscribes to **Device Manager** lifecycle events - -## API boundary - -- REST is used by the **Client** to build initial state and perform control operations (update settings, start/stop **Script Runtimes**). -- WebSocket streams real-time **Device Attribute** updates and **Device** lifecycle events to the **Client**, and is used to write **Device Attributes**. - -## Example dialogue - -> **Dev:** "When a device is plugged in, who creates it?" -> **Domain expert:** "The **Device Manager** announces a detected device. Each **Device Provider** that understands the hardware can try to claim it — first one wins. The claiming **Device Provider** then creates the **Device** and registers it with the **Device Manager**." - -> **Dev:** "Can an **Automation Script** start itself when a device connects?" -> **Domain expert:** "No — an **Automation Script** must be explicitly started by the **Operator** via the API. Once its **Script Runtime** is active, it can react to device connect and disconnect events." - -## Flagged ambiguities - -- "script" was used loosely to mean both the definition and the running instance — resolved: **Automation Script** (definition) vs **Script Runtime** (active execution). -- "connection" was used to mean both the physical channel and the protocol handshake — resolved: **Transport** (channel) vs the Device Provider's connect flow (handshake). From 54499671b6f37b694ef9f5a512ec080256133ef4 Mon Sep 17 00:00:00 2001 From: HRS Date: Mon, 8 Jun 2026 06:55:54 +0200 Subject: [PATCH 09/23] Fix typecheck --- tests/integration/automationScripts.spec.ts | 2 +- tests/unit/automation/scriptRuntime.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/automationScripts.spec.ts b/tests/integration/automationScripts.spec.ts index f68a7efa..1a086f79 100644 --- a/tests/integration/automationScripts.spec.ts +++ b/tests/integration/automationScripts.spec.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import request from 'supertest'; import { AppInstance } from '../../src/app.js'; -import { ScriptRuntime } from '../../src/automation/scriptRuntime.js'; +import ScriptRuntime from '../../src/automation/scriptRuntime.js'; import AutomationEventType from '../../src/automation/automationEventType.js'; import { TEST_DEVICE_ID, diff --git a/tests/unit/automation/scriptRuntime.spec.ts b/tests/unit/automation/scriptRuntime.spec.ts index e0a1a8d6..638ffdcf 100644 --- a/tests/unit/automation/scriptRuntime.spec.ts +++ b/tests/unit/automation/scriptRuntime.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { EventEmitter } from 'events'; import { tmpdir } from 'os'; -import { ScriptRuntime, SupportedDeviceEvent } from '../../../src/automation/scriptRuntime.js'; +import ScriptRuntime, { SupportedDeviceEvent } from '../../../src/automation/scriptRuntime.js'; import AutomationEventType from '../../../src/automation/automationEventType.js'; import { DeviceManagerEvent } from '../../../src/device/deviceManager.js'; import Device, { DeviceAttributes, ExtractAttributeValue } from '../../../src/device/device.js'; From 6e21ade7aed795cb12770ca6c86249de4e12e658 Mon Sep 17 00:00:00 2001 From: HRS Date: Mon, 8 Jun 2026 20:45:22 +0200 Subject: [PATCH 10/23] Review findings --- src/device/deviceManager.ts | 7 +++-- src/env.ts | 7 +++++ tests/integration/automationScripts.spec.ts | 32 ++++++++++----------- tests/integration/deviceEvents.spec.ts | 17 +++++++---- tests/integration/helpers/appHelper.ts | 12 ++++++-- 5 files changed, 48 insertions(+), 27 deletions(-) diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index 206b3817..81e4c5ba 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -159,10 +159,13 @@ export default class DeviceManager public async reset(): Promise { - for (const device of Array.from(this.connectedDevices.values())) { + for (const [, device] of this.connectedDevices) { await device.close(); } - this.detectedDeviceAcquireQueue.clear(); + + for (const [deviceId] of this.detectedDeviceAcquireQueue) { + this.clearDetectedDeviceAcquireQueue(deviceId, 'Device manager reset'); + } } private clearDetectedDeviceAcquireQueue(deviceId: string, reason: string): void diff --git a/src/env.ts b/src/env.ts index c5bafe97..ed2da815 100644 --- a/src/env.ts +++ b/src/env.ts @@ -19,5 +19,12 @@ export const parseEnv = (env: NodeJS.ProcessEnv): Env => { throw new Error(`Invalid environment variables:\n${errors.map(e => ` ${e.path}: ${e.message}`).join('\n')}`); } + const hasCertFile = converted.SSL_CERT_FILE !== undefined; + const hasKeyFile = converted.SSL_KEY_FILE !== undefined; + + if (hasCertFile !== hasKeyFile) { + throw new Error('Invalid environment variables:\n /SSL_CERT_FILE and /SSL_KEY_FILE must be set together'); + } + return Value.Decode(EnvSchema, converted); }; diff --git a/tests/integration/automationScripts.spec.ts b/tests/integration/automationScripts.spec.ts index 1a086f79..ca51eb58 100644 --- a/tests/integration/automationScripts.spec.ts +++ b/tests/integration/automationScripts.spec.ts @@ -11,11 +11,11 @@ import { connectDevices, } from './helpers/appHelper.js'; -function collectUntilMarker( +const collectUntilMarker = ( scriptRuntime: ScriptRuntime, marker: string, timeoutMs = 2000, -): Promise { +): Promise => { return new Promise((resolve, reject) => { const collected: string[] = []; @@ -37,6 +37,18 @@ function collectUntilMarker( }); } +const waitForEvent = (scriptRuntime: ScriptRuntime, eventType: AutomationEventType): Promise => { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${eventType}`)), 2000); + const listener = () => { + clearTimeout(timeout); + scriptRuntime.off(eventType, listener); + resolve(); + }; + scriptRuntime.on(eventType, listener); + }) +}; + describe('Automation scripts', () => { let instance: AppInstance; let tmpDir: string; @@ -57,13 +69,7 @@ describe('Automation scripts', () => { it('POST /automation/run loads the script and reports running status', async () => { const scriptRuntime = instance.container.get('automation.scriptRuntime'); - const scriptStarted = new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('Timed out waiting for scriptStarted')), 2000); - scriptRuntime.on(AutomationEventType.scriptStarted, () => { - clearTimeout(timeout); - resolve(); - }); - }); + const scriptStarted = waitForEvent(scriptRuntime, AutomationEventType.scriptStarted); const res = await request(instance.expressApp) .post('/automation/run') @@ -102,13 +108,7 @@ describe('Automation scripts', () => { .set('Content-Type', 'text/plain') .send('onEvent(() => {});'); - const scriptStopped = new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('Timed out waiting for scriptStopped')), 2000); - scriptRuntime.on(AutomationEventType.scriptStopped, () => { - clearTimeout(timeout); - resolve(); - }); - }); + const scriptStopped = waitForEvent(scriptRuntime, AutomationEventType.scriptStopped); const stopRes = await request(instance.expressApp).get('/automation/stop'); expect(stopRes.status).toBe(200); diff --git a/tests/integration/deviceEvents.spec.ts b/tests/integration/deviceEvents.spec.ts index 901a3a12..3ef395a0 100644 --- a/tests/integration/deviceEvents.spec.ts +++ b/tests/integration/deviceEvents.spec.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { AppInstance } from '../../src/app.js'; import { DeviceManagerEvent } from '../../src/device/deviceManager.js'; +import Device from '../../src/device/device.js'; import Settings from '../../src/settings/settings.js'; import KnownDevice from '../../src/settings/knownDevice.js'; import DeviceSource from '../../src/settings/deviceSource.js'; @@ -53,7 +54,7 @@ describe('Device events', () => { await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Timed out waiting for device value to change')), 1000); - deviceManager.on(DeviceManagerEvent.deviceRefreshed, async () => { + const listener = async () => { const value = (await device.getAttribute('value'))?.value; if (undefined === observedValue) { @@ -64,9 +65,11 @@ describe('Device events', () => { if (value !== observedValue) { changedValue = value; clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceRefreshed, listener); resolve(); } - }); + }; + deviceManager.on(DeviceManagerEvent.deviceRefreshed, listener); }); expect(observedValue).toBeDefined(); @@ -85,8 +88,8 @@ describe('Device events', () => { const devices = deviceManager.getConnectedDevices(); expect(devices).toHaveLength(2); - expect(devices[0].getDeviceId).toBe(TEST_DEVICE_ID); - expect(devices[1].getDeviceId).toBe(NEW_DEVICE_ID); + const actualDeviceIds = new Set(devices.map(d => d.getDeviceId)); + expect(actualDeviceIds).toEqual(new Set([TEST_DEVICE_ID, NEW_DEVICE_ID])); }, 1000); it('dynamically removes a virtual device deleted from settings', async () => { @@ -101,12 +104,14 @@ describe('Device events', () => { const deviceDisconnected = new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Timed out waiting for device to disconnect')), 1000); - deviceManager.on(DeviceManagerEvent.deviceDisconnected, (device) => { + const listener = (device: Device) => { if (device.getDeviceId === NEW_DEVICE_ID) { clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceDisconnected, listener); resolve(); } - }); + }; + deviceManager.on(DeviceManagerEvent.deviceDisconnected, listener); }); const settingsWithOneDevice = new Settings(); diff --git a/tests/integration/helpers/appHelper.ts b/tests/integration/helpers/appHelper.ts index 3cad2921..6b0838cb 100644 --- a/tests/integration/helpers/appHelper.ts +++ b/tests/integration/helpers/appHelper.ts @@ -76,15 +76,21 @@ export const resetTestApp = async (instance: AppInstance): Promise => { // Calling device.close() directly would leave those maps stale. const remaining = new Set(connectedIds); const allGone = new Promise((resolve, reject) => { + const cleanup = () => { + deviceManager.off(DeviceManagerEvent.deviceDisconnected, listener); + clearTimeout(timeout); + }; const timeout = setTimeout( - () => reject(new Error(`resetTestApp: ${[...remaining].join(', ')} did not disconnect within 1s`)), + () => { + cleanup(); + reject(new Error(`resetTestApp: ${[...remaining].join(', ')} did not disconnect within 1s`)); + }, 1000, ); const listener = (device: { getDeviceId: string }) => { remaining.delete(device.getDeviceId); if (remaining.size === 0) { - deviceManager.off(DeviceManagerEvent.deviceDisconnected, listener); - clearTimeout(timeout); + cleanup(); resolve(); } }; From dfbe08bebb8a66fccd61a10f18c082180e2f65e8 Mon Sep 17 00:00:00 2001 From: HRS Date: Tue, 9 Jun 2026 18:49:17 +0200 Subject: [PATCH 11/23] Many new improvements --- src/app.ts | 21 +- .../automation/stopScriptController.ts | 4 +- src/controller/healthController.ts | 11 +- src/env.ts | 4 +- src/health/healthMetricsCollector.ts | 28 +- src/index.ts | 1 + .../automationServiceProvider.ts | 9 +- .../repositoryServiceProvider.ts | 9 +- .../settingsServiceProvider.ts | 27 +- tests/integration/api.spec.ts | 291 ++++++++++++++++-- tests/integration/helpers/appHelper.ts | 20 +- .../unit/controller/healthController.spec.ts | 36 +++ vitest.config.integration.ts | 1 - 13 files changed, 385 insertions(+), 77 deletions(-) create mode 100644 tests/unit/controller/healthController.spec.ts diff --git a/src/app.ts b/src/app.ts index 09ff6e51..03c5dc0f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -32,7 +32,7 @@ export type { SslConfig }; export interface AppOptions { allowedOrigins?: string[]; sslConfig?: SslConfig; - settingsFilePath?: string; + dataPath?: string; } export interface AppInstance { @@ -75,12 +75,17 @@ const configureWebsocket = (container: Pimple): void => { const deviceDiscriminator = DeviceDiscriminator.createClassTransformerTypeDiscriminator('type'); - // Health metrics broadcast + // Health metrics: start background refresh, then broadcast cached value on each tick + healthMetricsCollector.start(1000); + setIntervalAsync(async () => { - io.emit(WebSocketEvent.healthMetrics, await healthMetricsCollector.collect()); + const metrics = healthMetricsCollector.collect(); + if (metrics !== null) { + io.emit(WebSocketEvent.healthMetrics, metrics); + } }, { intervalMs: 500, - timeoutMs: 2_000, + timeoutMs: 1_000, onError: (err) => logError(logger, 'Health metrics broadcast failed', err), }); @@ -132,7 +137,7 @@ const loadDeviceProviders = (container: Pimple): void => { }; export const createApp = (options: AppOptions = {}): AppInstance => { - const { allowedOrigins = [], sslConfig, settingsFilePath } = options; + const { allowedOrigins = [], sslConfig, dataPath } = options; const corsOptions: CorsOptions = { origin: (origin, callback) => { @@ -151,13 +156,13 @@ export const createApp = (options: AppOptions = {}): AppInstance => { .register(new LoggerServiceProvider()) .register(new HealthServiceProvider()) .register(new ServerServiceProvider(app, corsOptions, sslConfig)) - .register(new SettingsServiceProvider(settingsFilePath)) + .register(new SettingsServiceProvider(dataPath)) .register(new DeviceServiceProvider()) .register(new ControllerServiceProvider()) .register(new SocketServiceProvider()) - .register(new RepositoryServiceProvider()) + .register(new RepositoryServiceProvider(dataPath)) .register(new SerializationServiceProvider()) - .register(new AutomationServiceProvider()) + .register(new AutomationServiceProvider(dataPath)) .register(new FactoryServiceProvider()) .register(new SchemaValidationServiceProvider()) ; diff --git a/src/controller/automation/stopScriptController.ts b/src/controller/automation/stopScriptController.ts index 5e88e1c2..76749b86 100644 --- a/src/controller/automation/stopScriptController.ts +++ b/src/controller/automation/stopScriptController.ts @@ -11,9 +11,9 @@ export default class StopScriptController implements ControllerInterface this.scriptRuntime = scriptRuntime; } - public execute(req: Request, res: Response): void + public async execute(req: Request, res: Response): Promise { - this.scriptRuntime.stop(); + await this.scriptRuntime.stop(); res.sendStatus(200); } diff --git a/src/controller/healthController.ts b/src/controller/healthController.ts index f62a5351..20bdfa88 100644 --- a/src/controller/healthController.ts +++ b/src/controller/healthController.ts @@ -11,8 +11,15 @@ export default class HealthController implements ControllerInterface this.healthMetricsCollector = healthMetricsCollector; } - public async execute(req: Request, res: Response): Promise + public execute(_req: Request, res: Response): void { - res.json(await this.healthMetricsCollector.collect()); + const metrics = this.healthMetricsCollector.collect(); + + if (metrics === null) { + res.sendStatus(204); + return; + } + + res.json(metrics); } } diff --git a/src/env.ts b/src/env.ts index ed2da815..a5a63a66 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,5 +1,6 @@ import { Value } from '@sinclair/typebox/value'; import { Static, Type } from '@sinclair/typebox'; +import os from 'os'; const EnvSchema = Type.Object({ PORT: Type.Number({ default: 1337, minimum: 1, maximum: 65535 }), @@ -7,12 +8,13 @@ const EnvSchema = Type.Object({ SSL_CERT_FILE: Type.Optional(Type.String()), SSL_KEY_FILE: Type.Optional(Type.String()), ALLOWED_ORIGINS: Type.Optional(Type.String()), + DATA_PATH: Type.String({ default: `${os.homedir()}/.slvctrlplus` }), }); type Env = Static; export const parseEnv = (env: NodeJS.ProcessEnv): Env => { - const converted = Value.Convert(EnvSchema, { ...env }); + const converted = Value.Default(EnvSchema, Value.Convert(EnvSchema, { ...env })); if (!Value.Check(EnvSchema, converted)) { const errors = [...Value.Errors(EnvSchema, converted)]; diff --git a/src/health/healthMetricsCollector.ts b/src/health/healthMetricsCollector.ts index 9ba924c4..d553f548 100644 --- a/src/health/healthMetricsCollector.ts +++ b/src/health/healthMetricsCollector.ts @@ -1,6 +1,7 @@ import os from 'os'; import process from 'process'; import { NetworkStats, OSUtils } from 'node-os-utils'; +import { IntervalAsync, setIntervalAsync } from '../util/async.js'; export type HealthMetrics = { process: { @@ -39,6 +40,10 @@ export default class HealthMetricsCollector { private readonly osUtils: OSUtils; + private currentMetrics: HealthMetrics | null = null; + + private intervalHandle: IntervalAsync | null = null; + public constructor() { this.osUtils = new OSUtils({ @@ -47,7 +52,26 @@ export default class HealthMetricsCollector }); } - public async collect(): Promise + public start(intervalMs: number): void + { + this.intervalHandle = setIntervalAsync( + async () => await this.refresh(), + { intervalMs, timeoutMs: intervalMs * 3 }, + ); + } + + public stop(): void + { + this.intervalHandle?.clear(); + this.intervalHandle = null; + } + + public collect(): HealthMetrics | null + { + return this.currentMetrics; + } + + private async refresh(): Promise { const [cpuUsage, cpuInfo, cpuLoadAvg, memInfo, sysUptime, networkStats, networkInterfaces] = await Promise.all([ this.osUtils.cpu.usage(), @@ -59,7 +83,7 @@ export default class HealthMetricsCollector this.osUtils.network.interfaces(), ]); - return { + this.currentMetrics = { process: { memoryUsage: process.memoryUsage(), }, diff --git a/src/index.ts b/src/index.ts index 74d07f9f..43f1fad8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ const sslConfig: SslConfig | undefined = env.SSL_KEY_FILE !== undefined && env.S const app = createApp({ allowedOrigins, sslConfig, + dataPath: env.DATA_PATH, }); const logger = app.container.get('logger.default'); diff --git a/src/serviceProvider/automationServiceProvider.ts b/src/serviceProvider/automationServiceProvider.ts index 33e2b048..71a37fd5 100644 --- a/src/serviceProvider/automationServiceProvider.ts +++ b/src/serviceProvider/automationServiceProvider.ts @@ -1,14 +1,19 @@ import { Pimple, ServiceProvider } from '@timesplinter/pimple'; import ScriptRuntime from '../automation/scriptRuntime.js'; -import os from 'os'; import fs from 'fs'; import ServiceMap from '../serviceMap.js'; export default class AutomationServiceProvider implements ServiceProvider { + private readonly dataPath: string | undefined; + + public constructor(dataPath?: string) { + this.dataPath = dataPath; + } + public register(container: Pimple): void { container.set('automation.scriptRuntime', () => { - const logPath = `${os.homedir()}/.slvctrlplus/`; + const logPath = `${this.dataPath}/automation-logs/`; if (false === fs.existsSync(logPath)) { fs.mkdirSync(logPath); diff --git a/src/serviceProvider/repositoryServiceProvider.ts b/src/serviceProvider/repositoryServiceProvider.ts index b0092c17..141c831d 100644 --- a/src/serviceProvider/repositoryServiceProvider.ts +++ b/src/serviceProvider/repositoryServiceProvider.ts @@ -1,12 +1,17 @@ import { Pimple, ServiceProvider } from '@timesplinter/pimple'; import ConnectedDeviceRepository from '../repository/connectedDeviceRepository.js'; import AutomationScriptRepository from '../repository/automationScriptRepository.js'; -import os from 'os'; import fs from 'fs'; import ServiceMap from '../serviceMap.js'; export default class RepositoryServiceProvider implements ServiceProvider { + private readonly dataPath: string | undefined; + + public constructor(dataPath?: string) { + this.dataPath = dataPath; + } + public register(container: Pimple): void { container.set('repository.connectedDevices', () => { return new ConnectedDeviceRepository( @@ -15,7 +20,7 @@ export default class RepositoryServiceProvider implements ServiceProvider { - const scriptsPath = `${os.homedir()}/.slvctrlplus/automation-scripts/`; + const scriptsPath = `${this.dataPath}/automation-scripts/`; if (false === fs.existsSync(scriptsPath)) { fs.mkdirSync(scriptsPath); diff --git a/src/serviceProvider/settingsServiceProvider.ts b/src/serviceProvider/settingsServiceProvider.ts index 450c360d..ebf56e0e 100644 --- a/src/serviceProvider/settingsServiceProvider.ts +++ b/src/serviceProvider/settingsServiceProvider.ts @@ -8,10 +8,10 @@ import { fileURLToPath } from 'url'; export default class SettingsServiceProvider implements ServiceProvider { - private readonly customSettingsFilePath: string | undefined; + private readonly dataPath: string | undefined; - public constructor(settingsFilePath?: string) { - this.customSettingsFilePath = settingsFilePath; + public constructor(dataPath?: string) { + this.dataPath = dataPath; } public register(container: Pimple): void { @@ -25,25 +25,14 @@ export default class SettingsServiceProvider implements ServiceProvider { - let settingsFilePath: string; + const dataPath = this.dataPath ?? `${os.homedir()}/.slvctrlplus/`; - if (this.customSettingsFilePath !== undefined) { - settingsFilePath = this.customSettingsFilePath; - const settingsDir = path.dirname(settingsFilePath); - - if (false === fs.existsSync(settingsDir)) { - fs.mkdirSync(settingsDir, { recursive: true }); - } - } else { - const settingsPath = `${os.homedir()}/.slvctrlplus/`; - - if (false === fs.existsSync(settingsPath)) { - fs.mkdirSync(settingsPath); - } - - settingsFilePath = `${settingsPath}settings.json`; + if (false === fs.existsSync(dataPath)) { + fs.mkdirSync(dataPath, { recursive: true }); } + const settingsFilePath = `${dataPath}/settings.json`; + const settingsManager = new SettingsManager( settingsFilePath, container.get('serializer.plainToClass'), diff --git a/tests/integration/api.spec.ts b/tests/integration/api.spec.ts index 2873e508..41efd0ad 100644 --- a/tests/integration/api.spec.ts +++ b/tests/integration/api.spec.ts @@ -19,33 +19,268 @@ describe('REST API', () => { await resetTestApp(instance); }); - it('GET /devices returns connected devices', async () => { - const testDeviceName = 'Test Random Generator'; - - await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: testDeviceName }]); - - const res = await request(instance.expressApp).get('/devices'); - - expect(res.status).toBe(200); - expect(res.body.count).toBe(1); - expect(res.body.items[0]).toEqual(expect.objectContaining({ - deviceId: TEST_DEVICE_ID, - deviceName: testDeviceName, - provider: 'virtual', - deviceModel: 'randomGenerator', - fwVersion: '1.0.0', - state: 'READY', - type: 'virtual', - controllable: false, - config: { min: 0, max: 100 }, - attributes: { - value: expect.objectContaining({ - name: 'value', - label: 'Random number', - modifier: 'ro', - type: 'int', - }), - }, - })); + describe('GET /version', () => { + it('returns version string', async () => { + const res = await request(instance.expressApp).get('/version'); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('version'); + expect(typeof res.body.version).toBe('string'); + }); + }); + + describe('GET /health', () => { + it('returns 200 with metrics or 204 when not yet collected', async () => { + const res = await request(instance.expressApp).get('/health'); + + expect(res.status).toBeOneOf([200, 204]); + }); + }); + + describe('GET /devices', () => { + it('returns connected devices', async () => { + const testDeviceName = 'Test Random Generator'; + + await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: testDeviceName }]); + + const res = await request(instance.expressApp).get('/devices'); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(1); + expect(res.body.items[0]).toEqual(expect.objectContaining({ + deviceId: TEST_DEVICE_ID, + deviceName: testDeviceName, + provider: 'virtual', + deviceModel: 'randomGenerator', + fwVersion: '1.0.0', + state: 'READY', + type: 'virtual', + controllable: false, + config: { min: 0, max: 100 }, + attributes: { + value: expect.objectContaining({ + name: 'value', + label: 'Random number', + modifier: 'ro', + type: 'int', + }), + }, + })); + }); + + it('returns empty list when no devices connected', async () => { + const res = await request(instance.expressApp).get('/devices'); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(0); + expect(res.body.items).toEqual([]); + }); + }); + + describe('GET /device/:deviceId', () => { + it('returns a single connected device', async () => { + await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const res = await request(instance.expressApp).get(`/device/${TEST_DEVICE_ID}`); + + expect(res.status).toBe(200); + expect(res.body).toEqual(expect.objectContaining({ + deviceId: TEST_DEVICE_ID, + deviceName: 'Test Random Generator', + })); + }); + + it('returns 404 for unknown device', async () => { + const res = await request(instance.expressApp).get('/device/unknown-id'); + + expect(res.status).toBe(404); + }); + }); + + describe('PATCH /device/:deviceId', () => { + it('returns 404 for unknown device', async () => { + const res = await request(instance.expressApp) + .patch('/device/unknown-id') + .send({}); + + expect(res.status).toBe(404); + }); + + it('returns 202 for connected device', async () => { + await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const res = await request(instance.expressApp) + .patch(`/device/${TEST_DEVICE_ID}`) + .send({}); + + expect(res.status).toBe(202); + }); + }); + + describe('GET /settings', () => { + it('returns current settings as JSON', async () => { + const res = await request(instance.expressApp).get('/settings'); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/application\/json/); + expect(res.body).toHaveProperty('knownDevices'); + expect(res.body).toHaveProperty('deviceSources'); + }); + }); + + describe('PUT /settings', () => { + it('accepts valid settings and returns them', async () => { + const getRes = await request(instance.expressApp).get('/settings'); + const currentSettings = getRes.body; + + const res = await request(instance.expressApp) + .put('/settings') + .send(currentSettings); + + expect(res.status).toBe(200); + const body = JSON.parse(res.text); + expect(body).toHaveProperty('knownDevices'); + expect(body).toHaveProperty('deviceSources'); + }); + + it('returns 400 for invalid settings', async () => { + const res = await request(instance.expressApp) + .put('/settings') + .send({ invalid: true }); + + expect(res.status).toBe(400); + expect(res.body).toHaveProperty('message'); + expect(res.body).toHaveProperty('errors'); + }); + }); + + describe('GET /automation/scripts', () => { + it('returns empty list when no scripts exist', async () => { + const res = await request(instance.expressApp).get('/automation/scripts'); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(0); + expect(res.body.items).toEqual([]); + }); + + it('lists saved scripts', async () => { + await request(instance.expressApp) + .post('/automation/scripts/test.js') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + const res = await request(instance.expressApp).get('/automation/scripts'); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(1); + expect(res.body.items[0]).toEqual(expect.objectContaining({ fileName: 'test.js' })); + }); + }); + + describe('POST /automation/scripts/:fileName', () => { + it('creates a script and returns its content', async () => { + const script = 'onEvent(() => {});'; + + const res = await request(instance.expressApp) + .post('/automation/scripts/my-script.js') + .set('Content-Type', 'text/plain') + .send(script); + + expect(res.status).toBe(201); + expect(res.text).toBe(script); + }); + + it('returns 400 for invalid filename', async () => { + const res = await request(instance.expressApp) + .post('/automation/scripts/INVALID_NAME.js') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + expect(res.status).toBe(400); + }); + + it('returns 400 for wrong content type', async () => { + const res = await request(instance.expressApp) + .post('/automation/scripts/test.js') + .set('Content-Type', 'application/json') + .send(JSON.stringify({ script: 'onEvent(() => {});' })); + + expect(res.status).toBe(400); + }); + }); + + describe('GET /automation/scripts/:fileName', () => { + it('returns script content as plain text', async () => { + const script = 'onEvent(() => {});'; + + await request(instance.expressApp) + .post('/automation/scripts/read-test.js') + .set('Content-Type', 'text/plain') + .send(script); + + const res = await request(instance.expressApp).get('/automation/scripts/read-test.js'); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/plain/); + expect(res.text).toBe(script); + }); + + it('returns 404 for non-existent script', async () => { + const res = await request(instance.expressApp).get('/automation/scripts/nonexistent.js'); + + expect(res.status).toBe(404); + }); + + it('returns 400 for invalid filename', async () => { + const res = await request(instance.expressApp).get('/automation/scripts/INVALID_NAME.js'); + + expect(res.status).toBe(400); + }); + }); + + describe('DELETE /automation/scripts/:fileName', () => { + it('deletes a script and returns 204', async () => { + await request(instance.expressApp) + .post('/automation/scripts/to-delete.js') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + const res = await request(instance.expressApp).delete('/automation/scripts/to-delete.js'); + + expect(res.status).toBe(204); + + const getRes = await request(instance.expressApp).get('/automation/scripts/to-delete.js'); + expect(getRes.status).toBe(404); + }); + + it('returns 400 for invalid filename', async () => { + const res = await request(instance.expressApp).delete('/automation/scripts/INVALID_NAME.js'); + + expect(res.status).toBe(400); + }); + }); + + describe('GET /automation/log', () => { + it('returns log as plain text after a script has run', async () => { + await request(instance.expressApp) + .post('/automation/run') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + const res = await request(instance.expressApp).get('/automation/log'); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/plain/); + }); + }); + + describe('GET /automation/status', () => { + it('returns not running when no script is loaded', async () => { + const res = await request(instance.expressApp).get('/automation/status'); + + expect(res.status).toBe(200); + expect(res.body.running).toBe(false); + expect(res.body.runningSince).toBeNull(); + }); }); }); diff --git a/tests/integration/helpers/appHelper.ts b/tests/integration/helpers/appHelper.ts index 6b0838cb..fff3232d 100644 --- a/tests/integration/helpers/appHelper.ts +++ b/tests/integration/helpers/appHelper.ts @@ -15,7 +15,13 @@ export const NEW_DEVICE_ID = 'c3d4e5f6-3456-4321-abcd-ef1234567892'; export type DeviceSpec = { id: string, name: string, config?: { min: number, max: number } }; -const baseSettings = { +function makeBaseSettings(): Settings { + const settings = new Settings(); + settings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', { scanIntervalMs: 50 })); + return settings; +} + +const baseSettingsJson = { knownDevices: {}, deviceSources: { [TEST_SOURCE_ID]: { @@ -26,18 +32,12 @@ const baseSettings = { }, }; -function makeBaseSettings(): Settings { - const settings = new Settings(); - settings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', { scanIntervalMs: 50 })); - return settings; -} - export const createTestApp = async (): Promise<{ instance: AppInstance, tmpDir: string }> => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'slvctrlplus-test-')); - const settingsFilePath = path.join(tmpDir, 'settings.json'); - fs.writeFileSync(settingsFilePath, JSON.stringify(baseSettings)); + const dataPath = tmpDir + path.sep; + fs.writeFileSync(path.join(tmpDir, 'settings.json'), JSON.stringify(baseSettingsJson)); - const instance = createApp({ settingsFilePath }); + const instance = createApp({ dataPath }); await instance.container.get('device.provider.loader').loadFromSettings(); diff --git a/tests/unit/controller/healthController.spec.ts b/tests/unit/controller/healthController.spec.ts new file mode 100644 index 00000000..c900e288 --- /dev/null +++ b/tests/unit/controller/healthController.spec.ts @@ -0,0 +1,36 @@ +import { Request, Response } from 'express'; +import { describe, it, expect } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import HealthController from '../../../src/controller/healthController.js'; +import HealthMetricsCollector, { HealthMetrics } from '../../../src/health/healthMetricsCollector.js'; + +describe('HealthController', () => { + it('returns 204 when no metrics have been collected yet', () => { + const collector = mock(); + collector.collect.mockReturnValue(null); + + const req = mock(); + const res = mock(); + res.sendStatus.mockReturnValue(res); + + new HealthController(collector).execute(req, res); + + expect(res.sendStatus).toHaveBeenCalledWith(204); + expect(res.json).not.toHaveBeenCalled(); + }); + + it('returns 200 with metrics once collection has run', () => { + const metrics = mock(); + const collector = mock(); + collector.collect.mockReturnValue(metrics); + + const req = mock(); + const res = mock(); + res.json.mockReturnValue(res); + + new HealthController(collector).execute(req, res); + + expect(res.json).toHaveBeenCalledWith(metrics); + expect(res.sendStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/vitest.config.integration.ts b/vitest.config.integration.ts index 53ca38c5..573ee3e2 100644 --- a/vitest.config.integration.ts +++ b/vitest.config.integration.ts @@ -5,6 +5,5 @@ export default defineConfig({ environment: "node", setupFiles: ["./tests/vitest.setup.ts"], include: ["tests/integration/**/*.{test,spec}.?(c|m)[jt]s?(x)"], - fileParallelism: false, }, }); From 2adcd950e99276e38e8cc33eec9e79d63513edf4 Mon Sep 17 00:00:00 2001 From: HRS Date: Tue, 9 Jun 2026 18:50:53 +0200 Subject: [PATCH 12/23] Remove double new line --- resources/schemas/settings.schema.json | 1 - 1 file changed, 1 deletion(-) diff --git a/resources/schemas/settings.schema.json b/resources/schemas/settings.schema.json index 35cdd316..f0a743d2 100644 --- a/resources/schemas/settings.schema.json +++ b/resources/schemas/settings.schema.json @@ -38,4 +38,3 @@ "required": ["knownDevices", "deviceSources"], "additionalProperties": false } - From fd5cee4dd6bbfeddf3350e14c05a4a2cad3d7aeb Mon Sep 17 00:00:00 2001 From: HRS Date: Tue, 9 Jun 2026 19:02:17 +0200 Subject: [PATCH 13/23] Align vitest configs --- vitest.config.integration.ts | 2 +- vitest.config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/vitest.config.integration.ts b/vitest.config.integration.ts index 573ee3e2..895346b4 100644 --- a/vitest.config.integration.ts +++ b/vitest.config.integration.ts @@ -4,6 +4,6 @@ export default defineConfig({ test: { environment: "node", setupFiles: ["./tests/vitest.setup.ts"], - include: ["tests/integration/**/*.{test,spec}.?(c|m)[jt]s?(x)"], + include: ["tests/integration/**/*.spec.ts"], }, }); diff --git a/vitest.config.ts b/vitest.config.ts index 39d06980..f80b7830 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ test: { environment: "node", setupFiles: ["./tests/vitest.setup.ts"], - exclude: ["**/node_modules/**", "**/tests/integration/**"], + include: ["tests/unit/**/*.spec.ts"], coverage: { provider: "v8", reporter: ["text", "html", "lcov"], From 23233a50282e825b297bc3d8d0b52ba880d37064 Mon Sep 17 00:00:00 2001 From: HRS Date: Tue, 9 Jun 2026 19:04:13 +0200 Subject: [PATCH 14/23] Align test targets --- .github/workflows/test.yml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 58f13091..9463c96e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,7 +48,7 @@ jobs: npm run typecheck - name: Unit tests with coverage run: | - npm run coverage + npm run test:coverage - name: Integration tests run: | npm run test:integration diff --git a/package.json b/package.json index 18a7785d..10eaf73a 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "dev": "nodemon", "test:unit": "vitest run --config vitest.config.ts", "test:integration": "vitest run --config vitest.config.integration.ts", - "coverage": "vitest run --config vitest.config.ts --coverage", + "test:coverage": "vitest run --config vitest.config.ts --coverage", "typecheck": "tsc --noEmit && tsc --noEmit -p tests/tsconfig.json", "lint": "eslint -c eslint.config.ts .", "build": "tsc" From 01c828f44de3cba0d3932eb58f77c872f7f2b5e6 Mon Sep 17 00:00:00 2001 From: HRS Date: Tue, 9 Jun 2026 20:22:38 +0200 Subject: [PATCH 15/23] Review feedback --- src/app.ts | 4 ++-- src/health/healthMetricsCollector.ts | 13 +++++++++++-- src/index.ts | 2 +- src/serviceProvider/automationServiceProvider.ts | 4 ++-- src/serviceProvider/healthServiceProvider.ts | 4 +++- src/serviceProvider/repositoryServiceProvider.ts | 2 +- tests/integration/automationScripts.spec.ts | 5 ++++- 7 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/app.ts b/src/app.ts index 03c5dc0f..b65f4379 100644 --- a/src/app.ts +++ b/src/app.ts @@ -32,7 +32,7 @@ export type { SslConfig }; export interface AppOptions { allowedOrigins?: string[]; sslConfig?: SslConfig; - dataPath?: string; + dataPath: string; } export interface AppInstance { @@ -136,7 +136,7 @@ const loadDeviceProviders = (container: Pimple): void => { serialPortObserver.init().catch(e => logError(logger, `Initializing serial port observer failed`, e)); }; -export const createApp = (options: AppOptions = {}): AppInstance => { +export const createApp = (options: AppOptions): AppInstance => { const { allowedOrigins = [], sslConfig, dataPath } = options; const corsOptions: CorsOptions = { diff --git a/src/health/healthMetricsCollector.ts b/src/health/healthMetricsCollector.ts index d553f548..9d17a5c0 100644 --- a/src/health/healthMetricsCollector.ts +++ b/src/health/healthMetricsCollector.ts @@ -2,6 +2,8 @@ import os from 'os'; import process from 'process'; import { NetworkStats, OSUtils } from 'node-os-utils'; import { IntervalAsync, setIntervalAsync } from '../util/async.js'; +import Logger from '../logging/Logger.js'; +import { logError } from '../util/error.js'; export type HealthMetrics = { process: { @@ -40,12 +42,15 @@ export default class HealthMetricsCollector { private readonly osUtils: OSUtils; + private readonly logger: Logger; + private currentMetrics: HealthMetrics | null = null; private intervalHandle: IntervalAsync | null = null; - public constructor() + public constructor(logger: Logger) { + this.logger = logger; this.osUtils = new OSUtils({ cacheEnabled: true, cacheTTL: 60_000, @@ -54,9 +59,13 @@ export default class HealthMetricsCollector public start(intervalMs: number): void { + if (this.intervalHandle !== null) { + return; + } + this.intervalHandle = setIntervalAsync( async () => await this.refresh(), - { intervalMs, timeoutMs: intervalMs * 3 }, + { intervalMs, timeoutMs: intervalMs * 3, onError: (err) => logError(this.logger, `Health metrics refresh failed`, err) }, ); } diff --git a/src/index.ts b/src/index.ts index 43f1fad8..398136e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ import { parseEnv } from './env.js'; const env = parseEnv(process.env); -const allowedOrigins = undefined !== env.ALLOWED_ORIGINS && null !== env.ALLOWED_ORIGINS.length +const allowedOrigins = undefined !== env.ALLOWED_ORIGINS && env.ALLOWED_ORIGINS.length > 0 ? env.ALLOWED_ORIGINS.split(',') .map(origin => origin.trim()) .filter(origin => origin.length > 0) diff --git a/src/serviceProvider/automationServiceProvider.ts b/src/serviceProvider/automationServiceProvider.ts index 71a37fd5..ed553439 100644 --- a/src/serviceProvider/automationServiceProvider.ts +++ b/src/serviceProvider/automationServiceProvider.ts @@ -7,7 +7,7 @@ export default class AutomationServiceProvider implements ServiceProvider { public register(container: Pimple): void { - container.set('health.metricsCollector', () => new HealthMetricsCollector()); + container.set('health.metricsCollector', () => new HealthMetricsCollector( + container.get('logger.default'), + )); } } diff --git a/src/serviceProvider/repositoryServiceProvider.ts b/src/serviceProvider/repositoryServiceProvider.ts index 141c831d..cc61699b 100644 --- a/src/serviceProvider/repositoryServiceProvider.ts +++ b/src/serviceProvider/repositoryServiceProvider.ts @@ -23,7 +23,7 @@ export default class RepositoryServiceProvider implements ServiceProvider => { return new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${eventType}`)), 2000); const listener = () => { clearTimeout(timeout); scriptRuntime.off(eventType, listener); resolve(); }; + const timeout = setTimeout(() => { + scriptRuntime.off(eventType, listener); + reject(new Error(`Timed out waiting for ${eventType}`)); + }, 2000); scriptRuntime.on(eventType, listener); }) }; From 4f55e041184b2c07318e3a0ac678ee0fff33ef52 Mon Sep 17 00:00:00 2001 From: HRS Date: Tue, 9 Jun 2026 22:07:29 +0200 Subject: [PATCH 16/23] Minimum length for all strings is 1 --- src/env.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/env.ts b/src/env.ts index a5a63a66..7e9c26ac 100644 --- a/src/env.ts +++ b/src/env.ts @@ -5,10 +5,10 @@ import os from 'os'; const EnvSchema = Type.Object({ PORT: Type.Number({ default: 1337, minimum: 1, maximum: 65535 }), HTTPS_PORT: Type.Number({ default: 1338, minimum: 1, maximum: 65535 }), - SSL_CERT_FILE: Type.Optional(Type.String()), - SSL_KEY_FILE: Type.Optional(Type.String()), - ALLOWED_ORIGINS: Type.Optional(Type.String()), - DATA_PATH: Type.String({ default: `${os.homedir()}/.slvctrlplus` }), + SSL_CERT_FILE: Type.Optional(Type.String({ minimumLength: 1 })), + SSL_KEY_FILE: Type.Optional(Type.String({ minimumLength: 1 })), + ALLOWED_ORIGINS: Type.Optional(Type.String({ minimumLength: 1 })), + DATA_PATH: Type.String({ default: `${os.homedir()}/.slvctrlplus`, minimumLength: 1 }), }); type Env = Static; From e442ac6f922cb9be64030ad5155ac50e22c181dc Mon Sep 17 00:00:00 2001 From: HRS Date: Wed, 10 Jun 2026 20:47:37 +0200 Subject: [PATCH 17/23] Refactor deviceProviderManager --- src/app.ts | 8 +++-- .../protocol/virtual/virtualDeviceProvider.ts | 2 +- src/device/provider/deviceProvider.ts | 4 +-- ...iderLoader.ts => deviceProviderManager.ts} | 30 ++++++++----------- src/serviceMap.ts | 4 +-- src/serviceProvider/deviceServiceProvider.ts | 8 ++--- tests/integration/helpers/appHelper.ts | 4 +-- 7 files changed, 28 insertions(+), 32 deletions(-) rename src/device/provider/{deviceProviderLoader.ts => deviceProviderManager.ts} (64%) diff --git a/src/app.ts b/src/app.ts index b65f4379..0813439f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -128,9 +128,13 @@ const configureWebsocket = (container: Pimple): void => { const loadDeviceProviders = (container: Pimple): void => { const serialPortObserver = container.get('device.observer.serial'); const logger = container.get('logger.default'); + const settings = container.get('settings'); + const deviceProviderManager = container.get('device.provider.loader'); - container.get('device.provider.loader') - .loadFromSettings() + deviceProviderManager.loadFromSettings(settings); + + deviceProviderManager + .startProviders() .catch(e => logError(logger, `Loading device providers failed`, e)); serialPortObserver.init().catch(e => logError(logger, `Initializing serial port observer failed`, e)); diff --git a/src/device/protocol/virtual/virtualDeviceProvider.ts b/src/device/protocol/virtual/virtualDeviceProvider.ts index 730dd851..4806d93a 100644 --- a/src/device/protocol/virtual/virtualDeviceProvider.ts +++ b/src/device/protocol/virtual/virtualDeviceProvider.ts @@ -46,7 +46,7 @@ export default class VirtualDeviceProvider extends DeviceProvider { if (this.discoveryInterval !== undefined) { clearInterval(this.discoveryInterval); this.discoveryInterval = undefined; diff --git a/src/device/provider/deviceProvider.ts b/src/device/provider/deviceProvider.ts index f8bde9ad..c49d3e0b 100644 --- a/src/device/provider/deviceProvider.ts +++ b/src/device/provider/deviceProvider.ts @@ -26,7 +26,7 @@ export default abstract class DeviceProvider< return Promise.resolve(); } - public stop(): void { - // no-op by default + public async stop(): Promise { + return Promise.resolve(); } } diff --git a/src/device/provider/deviceProviderLoader.ts b/src/device/provider/deviceProviderManager.ts similarity index 64% rename from src/device/provider/deviceProviderLoader.ts rename to src/device/provider/deviceProviderManager.ts index 544ae6ad..fd11f116 100644 --- a/src/device/provider/deviceProviderLoader.ts +++ b/src/device/provider/deviceProviderManager.ts @@ -2,41 +2,32 @@ import Settings from '../../settings/settings.js'; import DeviceProviderFactory from './deviceProviderFactory.js'; import DeviceManager from '../deviceManager.js'; import Logger from '../../logging/Logger.js'; -import SerialPortObserver from '../transport/serialPortObserver.js'; import DeviceProvider from './deviceProvider.js'; import Device from '../device.js'; -export default class DeviceProviderLoader +export default class DeviceProviderManager { - private settings: Settings; - private factories: Map; private readonly deviceManager: DeviceManager; - private readonly serialPortObserver: SerialPortObserver; - private readonly logger: Logger; - private startedProviders: DeviceProvider[] = []; + private providers: DeviceProvider[] = []; public constructor( deviceManager: DeviceManager, - serialPortObserver: SerialPortObserver, - settings: Settings, factories: Map, logger: Logger ) { this.deviceManager = deviceManager; - this.serialPortObserver = serialPortObserver; - this.settings = settings; this.factories = factories; this.logger = logger; } - public async loadFromSettings(): Promise + public loadFromSettings(settings: Settings): void { - const configuredDeviceSources = this.settings.getDeviceSources(); + const configuredDeviceSources = settings.getDeviceSources(); this.logger.debug(`Found ${configuredDeviceSources.size} configured device source(s)`); @@ -50,14 +41,19 @@ export default class DeviceProviderLoader const provider = factory.create(deviceSource.config); + this.providers.push(provider); + } + } + + public async startProviders(): Promise { + for (const provider of this.providers) { await provider.init(); - this.startedProviders.push(provider); } } - public stop(): void { - for (const provider of this.startedProviders) { - provider.stop(); + public async stopProviders(): Promise { + for (const provider of this.providers) { + await provider.stop(); } } } diff --git a/src/serviceMap.ts b/src/serviceMap.ts index b1e38a2a..c06321ec 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -19,7 +19,7 @@ import SettingsManager from './settings/settingsManager.js'; import ScriptRuntime from './automation/scriptRuntime.js'; import ConnectedDeviceRepository from './repository/connectedDeviceRepository.js'; import AutomationScriptRepository from './repository/automationScriptRepository.js'; -import DeviceProviderLoader from './device/provider/deviceProviderLoader.js'; +import DeviceProviderManager from './device/provider/deviceProviderManager.js'; import DeviceUpdateHandler from './socket/deviceUpdateHandler.js'; import HealthController from './controller/healthController.js'; import HealthMetricsCollector from './health/healthMetricsCollector.js'; @@ -102,7 +102,7 @@ type ServiceMap = { 'repository.connectedDevices': ConnectedDeviceRepository, 'repository.automationScript': AutomationScriptRepository, - 'device.provider.loader': DeviceProviderLoader, + 'device.provider.loader': DeviceProviderManager, 'socket.deviceUpdateHandler': DeviceUpdateHandler, /* controllerServiceProvider */ diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index b569a47d..90418c36 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -8,7 +8,7 @@ import BufferedDeviceUpdater from '../device/updater/bufferedDeviceUpdater.js'; import GenericDeviceUpdater from '../device/genericDeviceUpdater.js'; import SerialDeviceTransportFactory from '../device/transport/serialDeviceTransportFactory.js'; import Device from '../device/device.js'; -import DeviceProviderLoader from '../device/provider/deviceProviderLoader.js'; +import DeviceProviderManager from '../device/provider/deviceProviderManager.js'; import SlvCtrlPlusSerialDeviceProvider from '../device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; import ButtplugIoWebsocketDeviceProvider from '../device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; import ButtplugIoWebsocketDeviceProviderFactory @@ -169,11 +169,9 @@ export default class DeviceServiceProvider implements ServiceProvider { - return new DeviceProviderLoader( + container.set('device.provider.loader', (): DeviceProviderManager => { + return new DeviceProviderManager( container.get('device.manager'), - container.get('device.observer.serial'), - container.get('settings'), new Map([ [ SlvCtrlPlusSerialDeviceProvider.providerName, diff --git a/tests/integration/helpers/appHelper.ts b/tests/integration/helpers/appHelper.ts index fff3232d..64bdf423 100644 --- a/tests/integration/helpers/appHelper.ts +++ b/tests/integration/helpers/appHelper.ts @@ -39,8 +39,6 @@ export const createTestApp = async (): Promise<{ instance: AppInstance, tmpDir: const instance = createApp({ dataPath }); - await instance.container.get('device.provider.loader').loadFromSettings(); - return { instance, tmpDir }; }; @@ -50,7 +48,7 @@ export const teardownTestApp = async (instance: AppInstance, tmpDir: string): Pr await scriptRuntime.stop(); } - instance.container.get('device.provider.loader').stop(); + instance.container.get('device.provider.loader').stopProviders(); await instance.container.get('device.manager').reset(); From 2502d9d752bae52961ac3c4eac28a228396ae6cb Mon Sep 17 00:00:00 2001 From: HRS Date: Wed, 10 Jun 2026 20:53:00 +0200 Subject: [PATCH 18/23] Remove trailing slashes for paths --- src/serviceProvider/automationServiceProvider.ts | 2 +- src/serviceProvider/repositoryServiceProvider.ts | 2 +- src/serviceProvider/settingsServiceProvider.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/serviceProvider/automationServiceProvider.ts b/src/serviceProvider/automationServiceProvider.ts index ed553439..7ba75f15 100644 --- a/src/serviceProvider/automationServiceProvider.ts +++ b/src/serviceProvider/automationServiceProvider.ts @@ -13,7 +13,7 @@ export default class AutomationServiceProvider implements ServiceProvider): void { container.set('automation.scriptRuntime', () => { - const logPath = `${this.dataPath}/automation-logs/`; + const logPath = `${this.dataPath}/automation-logs`; if (false === fs.existsSync(logPath)) { fs.mkdirSync(logPath, { recursive: true }); diff --git a/src/serviceProvider/repositoryServiceProvider.ts b/src/serviceProvider/repositoryServiceProvider.ts index cc61699b..e29fd9bf 100644 --- a/src/serviceProvider/repositoryServiceProvider.ts +++ b/src/serviceProvider/repositoryServiceProvider.ts @@ -20,7 +20,7 @@ export default class RepositoryServiceProvider implements ServiceProvider { - const scriptsPath = `${this.dataPath}/automation-scripts/`; + const scriptsPath = `${this.dataPath}/automation-scripts`; if (false === fs.existsSync(scriptsPath)) { fs.mkdirSync(scriptsPath, { recursive: true }); diff --git a/src/serviceProvider/settingsServiceProvider.ts b/src/serviceProvider/settingsServiceProvider.ts index ebf56e0e..08f64ae3 100644 --- a/src/serviceProvider/settingsServiceProvider.ts +++ b/src/serviceProvider/settingsServiceProvider.ts @@ -25,7 +25,7 @@ export default class SettingsServiceProvider implements ServiceProvider { - const dataPath = this.dataPath ?? `${os.homedir()}/.slvctrlplus/`; + const dataPath = this.dataPath ?? `${os.homedir()}/.slvctrlplus`; if (false === fs.existsSync(dataPath)) { fs.mkdirSync(dataPath, { recursive: true }); From a62792a058509a2e9d3a6b5406c2b86ed6e68d43 Mon Sep 17 00:00:00 2001 From: HRS Date: Wed, 10 Jun 2026 21:04:50 +0200 Subject: [PATCH 19/23] Fix paths --- src/repository/automationScriptRepository.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/repository/automationScriptRepository.ts b/src/repository/automationScriptRepository.ts index a587e0cc..4045c94b 100644 --- a/src/repository/automationScriptRepository.ts +++ b/src/repository/automationScriptRepository.ts @@ -27,7 +27,7 @@ export default class AutomationScriptRepository implements AutomationScriptRepos public getByName(name: string): string|null { try { - return fs.readFileSync(`${this.location}${name}`).toString(); + return fs.readFileSync(`${this.location}/${name}`).toString(); } catch (e: unknown) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') { return null; @@ -39,11 +39,11 @@ export default class AutomationScriptRepository implements AutomationScriptRepos public save(fileName: string, data: string): void { - fs.writeFileSync(`${this.location}${fileName}`, data); + fs.writeFileSync(`${this.location}/${fileName}`, data); } public delete(fileName: string): void { - fs.unlinkSync(`${this.location}${fileName}`); + fs.unlinkSync(`${this.location}/${fileName}`); } } From 70e71621babc43f895e94a60f9e5f7447738a9f8 Mon Sep 17 00:00:00 2001 From: HRS Date: Wed, 10 Jun 2026 22:37:39 +0200 Subject: [PATCH 20/23] Add serial and buttplugio integration tests --- .../protocol/buttplugIo/buttplugIoDevice.ts | 4 + .../buttplugIo/buttplugIoDeviceFactory.ts | 6 +- .../estim2b/estim2bSerialDeviceProvider.ts | 7 +- .../protocol/slvCtrlPlus/slvCtrlPlusDevice.ts | 2 +- .../slvCtrlPlusSerialDeviceProvider.ts | 9 +- .../protocol/zc95/zc95SerialDeviceProvider.ts | 16 +- src/device/provider/serialDeviceProvider.ts | 9 +- src/factory/serialPortFactory.ts | 4 +- src/util/async.ts | 13 +- tests/integration/buttplugIoDevice.spec.ts | 188 ++++++++++++ .../helpers/buttplugIoServerSimulator.ts | 274 ++++++++++++++++++ .../helpers/mockSerialPortFactory.ts | 38 +++ .../helpers/slvCtrlPlusDeviceSimulator.ts | 131 +++++++++ tests/integration/serialDevice.spec.ts | 160 ++++++++++ 14 files changed, 836 insertions(+), 25 deletions(-) create mode 100644 tests/integration/buttplugIoDevice.spec.ts create mode 100644 tests/integration/helpers/buttplugIoServerSimulator.ts create mode 100644 tests/integration/helpers/mockSerialPortFactory.ts create mode 100644 tests/integration/helpers/slvCtrlPlusDeviceSimulator.ts create mode 100644 tests/integration/serialDevice.spec.ts diff --git a/src/device/protocol/buttplugIo/buttplugIoDevice.ts b/src/device/protocol/buttplugIo/buttplugIoDevice.ts index 55e484e7..f14ade94 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDevice.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDevice.ts @@ -40,6 +40,10 @@ export default class ButtplugIoDevice extends Device this.deviceModel = deviceModel; } + public override get getRefreshInterval(): number { + return 100; + } + protected override async doRefresh(): Promise { for (const sensor of this.buttplugClientDevice.messageAttributes.SensorReadCmd ?? []) { const value = await this.buttplugClientDevice.sensorRead(sensor.Index, sensor.SensorType); diff --git a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts index c45eacb8..5e9b236c 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts @@ -88,14 +88,14 @@ export default class ButtplugIoDeviceFactory // A range is defined by two numbers, if there are more or less, let's fallback // to a normal integer attribute. Not that dramatic for a sensor after all. - if (item.StepRange.length === 2) { + if ('SensorRange' in item && Array.isArray(item.SensorRange) && item.SensorRange.length === 2) { attributes[attrName] = IntRangeDeviceAttribute.createInitialized( `${item.SensorType}-${item.Index}`, item.FeatureDescriptor, DeviceAttributeModifier.readOnly, undefined, - Int.from(item.StepRange[0]), - Int.from(item.StepRange[1]), + Int.from(item.SensorRange[0]), + Int.from(item.SensorRange[1]), Int.from(1), Int.ZERO ); diff --git a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts index 0492b79f..3696db73 100644 --- a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts +++ b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts @@ -1,5 +1,6 @@ -import { ReadlineParser, SerialPort } from 'serialport'; -import { PortInfo } from '@serialport/bindings-interface'; +import { ReadlineParser } from 'serialport'; +import { SerialPortStream } from '@serialport/stream'; +import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; import EventEmitter from 'events'; import Logger from '../../../logging/Logger.js'; import SerialDeviceProvider, { SerialDeviceProviderPortOpenOptions } from '../../provider/serialDeviceProvider.js'; @@ -37,7 +38,7 @@ export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider { + protected async connectSerialDevice(port: SerialPortStream, portInfo: PortInfo): Promise { const parser = port.pipe(new ReadlineParser({ delimiter: '\n' })); const syncPort = new SynchronousSerialPort(portInfo, parser, port, this.logger); const transport = this.transportFactory.create(syncPort, undefined, Buffer.from('\r')); diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts index b6a8f76a..75717f89 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts @@ -39,7 +39,7 @@ export default abstract class SlvCtrlPlusDevice< protected async send(command: SlvCtrlProtocolCommand): Promise { const encodedCommand = this.protocol.encode(command); - const response = await this.transport.sendAndAwaitReceive(encodedCommand); + const response = await this.transport.sendAndAwaitReceive(encodedCommand, SlvCtrlProtocol.transportTimeoutMs); const decodedResponse = this.protocol.decode(response); if ('error' in decodedResponse) { diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts index c2a18867..3c390121 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts @@ -1,5 +1,6 @@ -import { ReadlineParser, ReadyParser, SerialPort } from 'serialport'; -import type { PortInfo } from '@serialport/bindings-interface'; +import { ReadlineParser, ReadyParser } from 'serialport'; +import { SerialPortStream } from '@serialport/stream'; +import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; import SlvCtrlPlusDeviceFactory from './slvCtrlPlusDeviceFactory.js'; import SynchronousSerialPort from '../../../serial/synchronousSerialPort.js'; import EventEmitter from 'events'; @@ -40,7 +41,7 @@ export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvide this.deviceTransportFactory = deviceTransportFactory; } - protected async connectSerialDevice(port: SerialPort, portInfo: PortInfo): Promise { + protected async connectSerialDevice(port: SerialPortStream, portInfo: PortInfo): Promise { const parser = port.pipe(new ReadlineParser({ delimiter: SlvCtrlProtocol.EOF })); const syncPort = new SynchronousSerialPort(portInfo, parser, port, this.logger); const transport = this.deviceTransportFactory.create(syncPort, undefined, Buffer.from(SlvCtrlProtocol.EOF)); @@ -90,7 +91,7 @@ export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvide return { baudRate: 9600 }; } - protected override preparePort(port: SerialPort, portInfo: PortInfo): Promise { + protected override preparePort(port: SerialPortStream, portInfo: PortInfo): Promise { return new Promise((resolve, reject) => { if (portInfo.vendorId !== SlvCtrlPlusSerialDeviceProvider.arduinoVendorId) { // It's NOT an Arduino diff --git a/src/device/protocol/zc95/zc95SerialDeviceProvider.ts b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts index 2eb30811..8a0077ae 100644 --- a/src/device/protocol/zc95/zc95SerialDeviceProvider.ts +++ b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts @@ -1,5 +1,5 @@ -import { SerialPort } from 'serialport'; -import { PortInfo } from '@serialport/bindings-interface'; +import { SerialPortStream } from '@serialport/stream'; +import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; import EventEmitter from 'events'; import Logger from '../../../logging/Logger.js'; import SerialDeviceProvider, { SerialDeviceProviderPortOpenOptions } from '../../provider/serialDeviceProvider.js'; @@ -38,7 +38,7 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider { + protected async connectSerialDevice(port: SerialPortStream, portInfo: PortInfo): Promise { const serialLogger = this.logger.child({ name: Zc95Device.name }) const parser = port.pipe(new FrameParser({ stx: Zc95Protocol.STX, etx: Zc95Protocol.ETX })); @@ -90,10 +90,10 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider { + private async reset(port: SerialPortStream, close: boolean = false): Promise { return new Promise((resolve, reject) => { - port.write(Buffer.from([Zc95Protocol.EOT]), (writeErr) => { - if (writeErr) { + port.write(Buffer.from([Zc95Protocol.EOT]), (writeErr: Error | null | undefined) => { + if (null != writeErr) { reject(writeErr); return; } @@ -101,8 +101,8 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider EOT'); if (close) { - port.close((closeErr) => { - if (closeErr) { + port.close((closeErr: Error | null) => { + if (null != closeErr) { reject(closeErr); return; } diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index 629cf615..3c10ae7e 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -1,8 +1,9 @@ import DeviceProvider from './deviceProvider.js'; import EventEmitter from 'events'; import Logger from '../../logging/Logger.js'; -import { PortInfo } from '@serialport/bindings-interface'; -import { SerialPort, SerialPortOpenOptions } from 'serialport'; +import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; +import { SerialPortOpenOptions } from 'serialport'; +import { SerialPortStream } from '@serialport/stream'; import SerialPortFactory from '../../factory/serialPortFactory.js'; import { AutoDetectTypes } from '@serialport/bindings-cpp'; import BaseError from 'modern-errors'; @@ -119,11 +120,11 @@ export default abstract class SerialDeviceProvider< } // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected preparePort(port: SerialPort, portInfo: PortInfo): Promise { + protected preparePort(port: SerialPortStream, portInfo: PortInfo): Promise { return Promise.resolve(); } - protected abstract connectSerialDevice(port: SerialPort, portInfo: PortInfo): Promise; + protected abstract connectSerialDevice(port: SerialPortStream, portInfo: PortInfo): Promise; protected abstract getSerialDeviceProviderPortOpenOptions(portInfo: PortInfo): SerialDeviceProviderPortOpenOptions; } diff --git a/src/factory/serialPortFactory.ts b/src/factory/serialPortFactory.ts index d5fd376f..c4aa47ac 100644 --- a/src/factory/serialPortFactory.ts +++ b/src/factory/serialPortFactory.ts @@ -1,9 +1,11 @@ import { SerialPort, SerialPortOpenOptions } from 'serialport'; +import { SerialPortStream } from '@serialport/stream'; import { AutoDetectTypes } from '@serialport/bindings-cpp'; +import { BindingInterface } from '@serialport/bindings-interface'; export default class SerialPortFactory { - public create(options: SerialPortOpenOptions): SerialPort { + public create(options: SerialPortOpenOptions): SerialPortStream { return new SerialPort(options); } } diff --git a/src/util/async.ts b/src/util/async.ts index 030f80e5..8640d52b 100644 --- a/src/util/async.ts +++ b/src/util/async.ts @@ -43,7 +43,8 @@ export const setIntervalAsync = ( let timer: ReturnType | undefined; const loop = async (): Promise => { - const promises = [fn(...args)]; + const fnPromise = fn(...args); + const promises: Promise[] = [fnPromise]; let timeoutHandle: ReturnType | undefined; @@ -59,6 +60,16 @@ export const setIntervalAsync = ( try { await Promise.race(promises); } catch (err) { + if (err instanceof IntervalTimeoutError) { + // The timeout won the race but the underlying work is still running. + // Attach a handler so the root cause gets reported when it eventually rejects. + fnPromise.catch(fnErr => { + if (options.onError) { + options.onError(fnErr); + } + }); + } + if (options.onError) { options.onError(err); } else { diff --git a/tests/integration/buttplugIoDevice.spec.ts b/tests/integration/buttplugIoDevice.spec.ts new file mode 100644 index 00000000..eee73cd5 --- /dev/null +++ b/tests/integration/buttplugIoDevice.spec.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import EventEmitter from 'events'; +import { DeviceEvent } from '../../src/device/device.js'; +import Settings from '../../src/settings/settings.js'; +import DeviceManager, { DeviceManagerEvent } from '../../src/device/deviceManager.js'; +import ButtplugIoWebsocketDeviceProvider from '../../src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; +import ButtplugIoDeviceFactory from '../../src/device/protocol/buttplugIo/buttplugIoDeviceFactory.js'; +import UuidFactory from '../../src/factory/uuidFactory.js'; +import DateFactory from '../../src/factory/dateFactory.js'; +import EventEmitterFactory from '../../src/factory/eventEmitterFactory.js'; +import Logger from '../../src/logging/Logger.js'; +import { ButtplugIoServerSimulator } from './helpers/buttplugIoServerSimulator.js'; +import ButtplugIoDevice from '../../src/device/protocol/buttplugIo/buttplugIoDevice.js'; +import IntRangeDeviceAttribute from '../../src/device/attribute/intRangeDeviceAttribute.js'; +import BoolDeviceAttribute from '../../src/device/attribute/boolDeviceAttribute.js'; +import IntDeviceAttribute from '../../src/device/attribute/intDeviceAttribute.js'; +import { DeviceAttributeModifier } from '../../src/device/attribute/deviceAttribute.js'; +import Device from '../../src/device/device.js'; +import { Int } from '../../src/util/numbers.js'; + +const noopLogger: Logger = { + child: () => noopLogger, + trace: () => {}, + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + fatal: () => {}, +}; + +function waitForDeviceConnected(deviceManager: DeviceManager, timeoutMs = 3000): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + deviceManager.off(DeviceManagerEvent.deviceConnected, listener); + reject(new Error(`Timed out waiting for device to connect (>${timeoutMs}ms)`)); + }, timeoutMs); + + const listener = (device: Device): void => { + clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceConnected, listener); + resolve(device as ButtplugIoDevice); + }; + + deviceManager.on(DeviceManagerEvent.deviceConnected, listener); + }); +} + +describe('ButtplugIO websocket device', () => { + let simulator: ButtplugIoServerSimulator; + let provider: ButtplugIoWebsocketDeviceProvider; + let deviceManager: DeviceManager; + + beforeEach(async () => { + simulator = new ButtplugIoServerSimulator(); + const port = await simulator.start(); + + const deviceFactory = new ButtplugIoDeviceFactory( + new UuidFactory(), + new DateFactory(), + new EventEmitterFactory(), + new Settings(), + noopLogger, + ); + + deviceManager = new DeviceManager(new EventEmitter(), new Map(), noopLogger); + + provider = new ButtplugIoWebsocketDeviceProvider( + deviceManager, + new EventEmitter(), + deviceFactory, + `127.0.0.1:${port}`, + false, // autoScan + true, // useDeviceNameAsId + noopLogger, + ); + }); + + afterEach(async () => { + await provider.stop(); + await deviceManager.reset(); + await simulator.stop(); + }); + + it('connects and parses all attribute types from the device list', async () => { + // Single device with all three attribute mapping variants: + // actuator stepCount > 2 → IntRangeDeviceAttribute (write-only) + // actuator stepCount <= 2 → BoolDeviceAttribute (write-only) + // sensor with sensorRange → IntRangeDeviceAttribute (read-only) + simulator.addDevice({ + name: 'MockDevice', + actuators: [ + { featureDescriptor: 'Vibrator', actuatorType: 'Vibrate', stepCount: 20 }, + { featureDescriptor: 'Switch', actuatorType: 'Oscillate', stepCount: 2 }, + ], + sensors: [{ featureDescriptor: 'Pressure', sensorType: 'Pressure', sensorRange: [0, 100] }], + }); + + const deviceConnected = waitForDeviceConnected(deviceManager); + await provider.init(); + const device = await deviceConnected; + + expect(device).toBeInstanceOf(ButtplugIoDevice); + + const vibe = await device.getAttribute('Vibrate-0') as IntRangeDeviceAttribute; + expect(vibe).toBeInstanceOf(IntRangeDeviceAttribute); + expect(vibe.modifier).toBe(DeviceAttributeModifier.writeOnly); + expect(vibe.min).toBe(0); + expect(vibe.max).toBe(20); + + const oscillate = await device.getAttribute('Oscillate-1'); + expect(oscillate).toBeInstanceOf(BoolDeviceAttribute); + expect(oscillate?.modifier).toBe(DeviceAttributeModifier.writeOnly); + + const pressure = await device.getAttribute('Pressure-0') as IntRangeDeviceAttribute; + expect(pressure).toBeInstanceOf(IntRangeDeviceAttribute); + expect(pressure.modifier).toBe(DeviceAttributeModifier.readOnly); + expect(pressure.min).toBe(0); + expect(pressure.max).toBe(100); + }); + + it('picks up a device added via push after initial connection', async () => { + await provider.init(); + await new Promise(resolve => setTimeout(resolve, 200)); + + const deviceConnected = waitForDeviceConnected(deviceManager); + simulator.addDevice({ + name: 'LateDevice', + actuators: [{ featureDescriptor: 'Vibrator', actuatorType: 'Vibrate', stepCount: 10 }], + }); + + expect(await deviceConnected).toBeInstanceOf(ButtplugIoDevice); + }); + + it('setAttribute sends a ScalarCmd with the correct normalised scalar value', async () => { + simulator.addDevice({ + name: 'MockVibe', + actuators: [{ featureDescriptor: 'Vibrator', actuatorType: 'Vibrate', stepCount: 20 }], + }); + + const deviceConnected = waitForDeviceConnected(deviceManager); + await provider.init(); + const device = await deviceConnected; + + await device.setAttribute('Vibrate-0', Int.from(10)); + + expect(simulator.receivedScalarCmds).toHaveLength(1); + const cmd = simulator.receivedScalarCmds[0]; + expect(cmd?.actuatorType).toBe('Vibrate'); + expect(cmd?.index).toBe(0); + expect(cmd?.scalar).toBeCloseTo(0.5); // 10 / 20 + }); + + it('polls sensor values automatically at the configured 100ms interval', async () => { + const deviceIndex = simulator.addDevice({ + name: 'MockSensor', + sensors: [{ featureDescriptor: 'Pressure', sensorType: 'Pressure', sensorRange: [0, 100], reading: 77 }], + }); + + const deviceConnected = waitForDeviceConnected(deviceManager); + await provider.init(); + const device = await deviceConnected; + + simulator.setSensorReading(deviceIndex, 0, 77); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for device refresh')), 1000); + device.on(DeviceEvent.deviceRefreshed, () => { clearTimeout(timeout); resolve(); }); + }); + + expect((await device.getAttribute('Pressure-0'))?.value).toBe(77); + }); + + it('refresh reads the current sensor value from the server', async () => { + const deviceIndex = simulator.addDevice({ + name: 'MockSensor', + sensors: [{ featureDescriptor: 'Pressure', sensorType: 'Pressure', sensorRange: [0, 100], reading: 42 }], + }); + + const deviceConnected = waitForDeviceConnected(deviceManager); + await provider.init(); + const device = await deviceConnected; + + simulator.setSensorReading(deviceIndex, 0, 42); + await device.refresh(); + + expect((await device.getAttribute('Pressure-0') as IntDeviceAttribute).value).toBe(42); + }); +}); diff --git a/tests/integration/helpers/buttplugIoServerSimulator.ts b/tests/integration/helpers/buttplugIoServerSimulator.ts new file mode 100644 index 00000000..7c04436d --- /dev/null +++ b/tests/integration/helpers/buttplugIoServerSimulator.ts @@ -0,0 +1,274 @@ +import { WebSocketServer, WebSocket } from 'ws'; +import { createServer, IncomingMessage } from 'http'; + +/** + * Defines a mock actuator on a simulated Buttplug device. + */ +export interface MockActuator { + featureDescriptor: string; + actuatorType: string; + stepCount: number; +} + +/** + * Defines a mock sensor on a simulated Buttplug device. + * sensorRange is [min, max]. + */ +export interface MockSensor { + featureDescriptor: string; + sensorType: string; + sensorRange: [number, number]; + /** Current reading to return for SensorReadCmd (defaults to 0). */ + reading?: number; +} + +export interface MockButtplugDevice { + name: string; + displayName?: string; + actuators?: MockActuator[]; + sensors?: MockSensor[]; +} + +interface ButtplugMessage { + [type: string]: { Id: number; [key: string]: unknown }; +} + +/** + * Minimal WebSocket server that speaks the Buttplug JSON protocol v3. + * + * Usage: + * const sim = new ButtplugIoServerSimulator(); + * const port = await sim.start(); + * // address used in provider config: `localhost:${port}` + * sim.addDevice({ name: 'MockVibe', actuators: [...], sensors: [...] }); + * ... + * await sim.stop(); + */ +export class ButtplugIoServerSimulator { + private server: ReturnType | null = null; + private wss: WebSocketServer | null = null; + private connectedClients: Set = new Set(); + + private devices: Map = new Map(); + private nextDeviceIndex = 0; + + /** Recorded scalar commands: { deviceIndex, index, actuatorType, scalar }[] */ + public receivedScalarCmds: Array<{ + deviceIndex: number; + index: number; + actuatorType: string; + scalar: number; + }> = []; + + public async start(): Promise { + return new Promise((resolve, reject) => { + this.server = createServer(); + this.wss = new WebSocketServer({ server: this.server, path: '/buttplug' }); + + this.wss.on('connection', (ws: WebSocket) => { + this.connectedClients.add(ws); + ws.on('message', (data: Buffer) => this.handleMessage(ws, data.toString())); + ws.on('close', () => this.connectedClients.delete(ws)); + ws.on('error', () => this.connectedClients.delete(ws)); + }); + + this.server.listen(0, '127.0.0.1', () => { + const addr = this.server?.address(); + if (addr && typeof addr === 'object') { + resolve(addr.port); + } else { + reject(new Error('Could not determine server port')); + } + }); + + this.server.on('error', reject); + }); + } + + public async stop(): Promise { + for (const ws of this.connectedClients) { + ws.close(); + } + this.connectedClients.clear(); + + await new Promise((resolve, reject) => { + this.wss?.close(err => (err ? reject(err) : resolve())); + }); + await new Promise((resolve, reject) => { + this.server?.close(err => (err ? reject(err) : resolve())); + }); + } + + /** + * Add a device and notify all connected clients with a DeviceAdded push. + * Returns the assigned device index. + */ + public addDevice(device: MockButtplugDevice): number { + const index = this.nextDeviceIndex++; + this.devices.set(index, device); + + const msg = this.buildDeviceAddedMessage(index, device); + this.broadcast(msg); + + return index; + } + + /** + * Remove a device and notify all connected clients with a DeviceRemoved push. + */ + public removeDevice(deviceIndex: number): void { + this.devices.delete(deviceIndex); + this.broadcast(`[{"DeviceRemoved":{"Id":0,"DeviceIndex":${deviceIndex}}}]`); + } + + public setSensorReading(deviceIndex: number, sensorIndex: number, value: number): void { + const device = this.devices.get(deviceIndex); + const sensor = device?.sensors?.[sensorIndex]; + if (sensor) { + sensor.reading = value; + } + } + + private handleMessage(ws: WebSocket, raw: string): void { + let messages: ButtplugMessage[]; + try { + messages = JSON.parse(raw) as ButtplugMessage[]; + } catch { + return; + } + + for (const msg of messages) { + const type = Object.keys(msg)[0]; + if (undefined === type) continue; + const payload = msg[type]; + if (undefined === payload) continue; + + this.handleSingleMessage(ws, type, payload); + } + } + + private handleSingleMessage( + ws: WebSocket, + type: string, + payload: { Id: number; [key: string]: unknown } + ): void { + const id = payload.Id; + + switch (type) { + case 'RequestServerInfo': + ws.send( + `[{"ServerInfo":{"Id":${id},"MessageVersion":3,"MaxPingTime":0,"ServerName":"MockButtplugServer"}}]` + ); + break; + + case 'RequestDeviceList': { + const deviceList = [...this.devices.entries()].map(([idx, dev]) => + JSON.stringify(this.buildDeviceInfo(idx, dev)) + ); + ws.send( + `[{"DeviceList":{"Id":${id},"Devices":[${deviceList.join(',')}]}}]` + ); + break; + } + + case 'StartScanning': + ws.send(`[{"Ok":{"Id":${id}}}]`); + ws.send(`[{"ScanningFinished":{"Id":0}}]`); + break; + + case 'StopScanning': + ws.send(`[{"Ok":{"Id":${id}}}]`); + break; + + case 'ScalarCmd': { + const scalars = payload.Scalars as Array<{ + Index: number; + Scalar: number; + ActuatorType: string; + }>; + const deviceIndex = payload.DeviceIndex as number; + if (Array.isArray(scalars)) { + for (const s of scalars) { + this.receivedScalarCmds.push({ + deviceIndex, + index: s.Index, + actuatorType: s.ActuatorType, + scalar: s.Scalar, + }); + } + } + ws.send(`[{"Ok":{"Id":${id}}}]`); + break; + } + + case 'SensorReadCmd': { + const deviceIndex = payload.DeviceIndex as number; + const sensorIndex = payload.SensorIndex as number; + const sensorType = payload.SensorType as string; + const device = this.devices.get(deviceIndex); + const sensor = device?.sensors?.[sensorIndex]; + const reading = sensor?.reading ?? 0; + ws.send( + `[{"SensorReading":{"Id":${id},"DeviceIndex":${deviceIndex},"SensorIndex":${sensorIndex},"SensorType":"${sensorType}","Data":[${reading},0]}}]` + ); + break; + } + + case 'StopDeviceCmd': + case 'StopAllDevices': + case 'Ping': + ws.send(`[{"Ok":{"Id":${id}}}]`); + break; + + default: + ws.send( + `[{"Error":{"Id":${id},"ErrorMessage":"Unknown message: ${type}","ErrorCode":0}}]` + ); + } + } + + private broadcast(msg: string): void { + for (const ws of this.connectedClients) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(msg); + } + } + } + + private buildDeviceAddedMessage(index: number, device: MockButtplugDevice): string { + return `[{"DeviceAdded":${JSON.stringify({ + Id: 0, + ...this.buildDeviceInfo(index, device), + })}}]`; + } + + private buildDeviceInfo(index: number, device: MockButtplugDevice): object { + const deviceMessages: Record = {}; + + if (device.actuators && device.actuators.length > 0) { + deviceMessages['ScalarCmd'] = device.actuators.map((a, i) => ({ + FeatureDescriptor: a.featureDescriptor, + ActuatorType: a.actuatorType, + StepCount: a.stepCount, + Index: i, + })); + } + + if (device.sensors && device.sensors.length > 0) { + deviceMessages['SensorReadCmd'] = device.sensors.map((s, i) => ({ + FeatureDescriptor: s.featureDescriptor, + SensorType: s.sensorType, + SensorRange: s.sensorRange, + Index: i, + })); + } + + return { + DeviceIndex: index, + DeviceName: device.name, + DeviceDisplayName: device.displayName ?? device.name, + DeviceMessageTimingGap: 0, + DeviceMessages: deviceMessages, + }; + } +} diff --git a/tests/integration/helpers/mockSerialPortFactory.ts b/tests/integration/helpers/mockSerialPortFactory.ts new file mode 100644 index 00000000..877cf8eb --- /dev/null +++ b/tests/integration/helpers/mockSerialPortFactory.ts @@ -0,0 +1,38 @@ +import { SerialPortMock } from 'serialport'; +import { SerialPortStream } from '@serialport/stream'; +import { BindingInterface } from '@serialport/bindings-interface'; +import { SerialPortOpenOptions } from 'serialport'; +import { AutoDetectTypes } from '@serialport/bindings-cpp'; +import SerialPortFactory from '../../../src/factory/serialPortFactory.js'; +import { SlvCtrlPlusDeviceSimulator } from './slvCtrlPlusDeviceSimulator.js'; + +/** + * A SerialPortFactory that creates SerialPortMock instances backed by + * MockBinding instead of real hardware. The provided simulator is wired + * up to each created port as soon as it opens. + */ +export default class MockSerialPortFactory extends SerialPortFactory { + private readonly simulator: SlvCtrlPlusDeviceSimulator; + + public constructor(simulator: SlvCtrlPlusDeviceSimulator) { + super(); + this.simulator = simulator; + } + + public override create(options: SerialPortOpenOptions): SerialPortStream { + const mockPort = new SerialPortMock({ + path: options.path, + baudRate: options.baudRate ?? 9600, + autoOpen: false, + }); + + // Attach the simulator once the port binding is ready (after open). + mockPort.once('open', () => { + if (mockPort.port) { + this.simulator.attachToPort(mockPort.port); + } + }); + + return mockPort; + } +} diff --git a/tests/integration/helpers/slvCtrlPlusDeviceSimulator.ts b/tests/integration/helpers/slvCtrlPlusDeviceSimulator.ts new file mode 100644 index 00000000..c50f30f1 --- /dev/null +++ b/tests/integration/helpers/slvCtrlPlusDeviceSimulator.ts @@ -0,0 +1,131 @@ +import type { SerialPortMock } from 'serialport'; + +type MockPortBinding = NonNullable['port']>; + +export interface SlvCtrlPlusDeviceSimulatorConfig { + deviceType?: string; + fwVersion?: number; + protocolVersion?: number; +} + +/** + * Simulates a SlvCtrl+ V1 protocol device backed by a MockPortBinding. + * + * It intercepts writes from the application, parses the command, and emits + * the appropriate protocol response via emitData(). The test device exposes + * all supported attribute types so every parsing path can be exercised. + * + * Attribute map (name → definition): + * connected ro[bool] + * enabled rw[bool] + * counter ro[int] + * level rw[int] + * temperature ro[float] + * gain rw[float] + * label ro[str] + * mode rw[str] + * intensity rw[int(0..100)] + * preset rw[str(low|medium|high)] + * channel rw[int(1|2|3)] + */ +export class SlvCtrlPlusDeviceSimulator { + private readonly deviceType: string; + private readonly fwVersion: number; + private readonly protocolVersion: number; + + private readonly attributeDefs: string[] = [ + 'connected:ro[bool]', + 'enabled:rw[bool]', + 'counter:ro[int]', + 'level:rw[int]', + 'temperature:ro[float]', + 'gain:rw[float]', + 'label:ro[str]', + 'mode:rw[str]', + 'intensity:rw[int(0..100)]', + 'preset:rw[str(low|medium|high)]', + 'channel:rw[int(1|2|3)]', + ]; + + private values: Record = { + connected: '1', + enabled: '0', + counter: '42', + level: '5', + temperature: '36.6', + gain: '0.5', + label: 'TestDevice', + mode: 'manual', + intensity: '0', + preset: 'low', + channel: '1', + }; + + public constructor(config: SlvCtrlPlusDeviceSimulatorConfig = {}) { + this.deviceType = config.deviceType ?? 'testDevice'; + this.fwVersion = config.fwVersion ?? 1; + this.protocolVersion = config.protocolVersion ?? 1; + } + + /** Wire the simulator to a MockPortBinding once the port is open. */ + public attachToPort(bindingPort: MockPortBinding): void { + const originalWrite = bindingPort.write.bind(bindingPort); + + bindingPort.write = async (buffer: Buffer): Promise => { + await originalWrite(buffer); + + const command = buffer.toString('utf-8').trimEnd(); + const response = this.respond(command); + + if (null !== response) { + // Use setImmediate so the response lands after writeAndExpect sets up its + // data listener (the listener is registered before write() returns). + setImmediate(() => { + bindingPort.emitData(Buffer.from(response + '\n', 'utf-8')); + }); + } + }; + } + + public getValue(name: string): string | undefined { + return this.values[name]; + } + + public setValue(name: string, value: string): void { + this.values[name] = value; + } + + private respond(command: string): string | null { + if (command === 'clear') { + return 'clear;;status:ok'; + } + + if (command === 'introduce') { + return `introduce;type:${this.deviceType},fw:${this.fwVersion},protocol:${this.protocolVersion};status:ok`; + } + + if (command === 'attributes') { + const attrStr = this.attributeDefs.join(','); + return `attributes;${attrStr};status:ok`; + } + + if (command === 'status') { + const statusStr = Object.entries(this.values) + .map(([k, v]) => `${k}:${v}`) + .join(','); + return `status;${statusStr};status:ok`; + } + + if (command.startsWith('set ')) { + const parts = command.split(' '); + const attrName = parts[1]; + const value = parts.slice(2).join(' '); + if (undefined !== attrName) { + this.values[attrName] = value; + } + return `${command};value:${value};status:ok`; + } + + return null; + } +} diff --git a/tests/integration/serialDevice.spec.ts b/tests/integration/serialDevice.spec.ts new file mode 100644 index 00000000..bedc2d76 --- /dev/null +++ b/tests/integration/serialDevice.spec.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { SerialPortMock } from 'serialport'; +import EventEmitter from 'events'; +import { adjectives, animals } from 'unique-names-generator'; +import { PortInfo } from '@serialport/bindings-interface'; +import Settings from '../../src/settings/settings.js'; +import DeviceManager from '../../src/device/deviceManager.js'; +import SlvCtrlPlusSerialDeviceProvider from '../../src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; +import SlvCtrlPlusDeviceFactory from '../../src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.js'; +import SerialDeviceTransportFactory from '../../src/device/transport/serialDeviceTransportFactory.js'; +import UuidFactory from '../../src/factory/uuidFactory.js'; +import DateFactory from '../../src/factory/dateFactory.js'; +import EventEmitterFactory from '../../src/factory/eventEmitterFactory.js'; +import DeviceNameGenerator from '../../src/device/deviceNameGenerator.js'; +import MockSerialPortFactory from './helpers/mockSerialPortFactory.js'; +import { SlvCtrlPlusDeviceSimulator } from './helpers/slvCtrlPlusDeviceSimulator.js'; +import BoolDeviceAttribute from '../../src/device/attribute/boolDeviceAttribute.js'; +import IntDeviceAttribute from '../../src/device/attribute/intDeviceAttribute.js'; +import FloatDeviceAttribute from '../../src/device/attribute/floatDeviceAttribute.js'; +import StrDeviceAttribute from '../../src/device/attribute/strDeviceAttribute.js'; +import IntRangeDeviceAttribute from '../../src/device/attribute/intRangeDeviceAttribute.js'; +import ListDeviceAttribute from '../../src/device/attribute/listDeviceAttribute.js'; +import { DeviceAttributeModifier } from '../../src/device/attribute/deviceAttribute.js'; +import Logger from '../../src/logging/Logger.js'; +import GenericSlvCtrlPlusDevice from '../../src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.js'; +import { Int } from '../../src/util/numbers.js'; + +const TEST_PORT_PATH = '/dev/test-slvctrl-0'; + +const noopLogger: Logger = { + child: () => noopLogger, + trace: () => {}, + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + fatal: () => {}, +}; + +function makePortInfo(): PortInfo { + return { + path: TEST_PORT_PATH, + manufacturer: 'TestMfg', + serialNumber: 'SN-TEST-001', + pnpId: undefined, + locationId: undefined, + vendorId: '1234', // non-Arduino vendorId skips the ReadyParser step + productId: '5678', + }; +} + +describe('SlvCtrl+ serial device', () => { + let provider: SlvCtrlPlusSerialDeviceProvider; + let simulator: SlvCtrlPlusDeviceSimulator; + + beforeEach(() => { + SerialPortMock.binding.createPort(TEST_PORT_PATH, { echo: false, record: false }); + + simulator = new SlvCtrlPlusDeviceSimulator(); + const mockPortFactory = new MockSerialPortFactory(simulator); + + const settings = new Settings(); + const deviceFactory = new SlvCtrlPlusDeviceFactory( + new UuidFactory(), + new DateFactory(), + new EventEmitterFactory(), + settings, + new DeviceNameGenerator({ dictionaries: [adjectives, animals], separator: '-', length: 2 }), + noopLogger, + ); + + provider = new SlvCtrlPlusSerialDeviceProvider( + new DeviceManager(new EventEmitter(), new Map(), noopLogger), + mockPortFactory, + new EventEmitter(), + deviceFactory, + new SerialDeviceTransportFactory(), + noopLogger, + ); + }); + + afterEach(() => { + SerialPortMock.binding.reset(); + }); + + it('connects successfully and returns a device', async () => { + const device = await provider.connectToDevice(makePortInfo()); + + expect(device).toBeInstanceOf(GenericSlvCtrlPlusDevice); + }); + + it('discovers all attribute types with correct types and modifiers', async () => { + const device = (await provider.connectToDevice(makePortInfo())) as GenericSlvCtrlPlusDevice; + + // bool + expect(await device.getAttribute('connected')).toBeInstanceOf(BoolDeviceAttribute); + expect((await device.getAttribute('connected'))?.modifier).toBe(DeviceAttributeModifier.readOnly); + expect(await device.getAttribute('enabled')).toBeInstanceOf(BoolDeviceAttribute); + expect((await device.getAttribute('enabled'))?.modifier).toBe(DeviceAttributeModifier.readWrite); + + // int + expect(await device.getAttribute('counter')).toBeInstanceOf(IntDeviceAttribute); + expect((await device.getAttribute('counter'))?.modifier).toBe(DeviceAttributeModifier.readOnly); + expect(await device.getAttribute('level')).toBeInstanceOf(IntDeviceAttribute); + expect((await device.getAttribute('level'))?.modifier).toBe(DeviceAttributeModifier.readWrite); + + // float + expect(await device.getAttribute('temperature')).toBeInstanceOf(FloatDeviceAttribute); + expect((await device.getAttribute('temperature'))?.modifier).toBe(DeviceAttributeModifier.readOnly); + expect(await device.getAttribute('gain')).toBeInstanceOf(FloatDeviceAttribute); + expect((await device.getAttribute('gain'))?.modifier).toBe(DeviceAttributeModifier.readWrite); + + // string + expect(await device.getAttribute('label')).toBeInstanceOf(StrDeviceAttribute); + expect(await device.getAttribute('mode')).toBeInstanceOf(StrDeviceAttribute); + + // int range with bounds + const intensity = await device.getAttribute('intensity') as IntRangeDeviceAttribute; + expect(intensity).toBeInstanceOf(IntRangeDeviceAttribute); + expect(intensity.min).toBe(0); + expect(intensity.max).toBe(100); + + // string list and int list + expect(await device.getAttribute('preset')).toBeInstanceOf(ListDeviceAttribute); + expect(await device.getAttribute('channel')).toBeInstanceOf(ListDeviceAttribute); + }); + + it('updates all attribute values after refresh', async () => { + const device = (await provider.connectToDevice(makePortInfo())) as GenericSlvCtrlPlusDevice; + + simulator.setValue('level', '7'); + simulator.setValue('enabled', '1'); + simulator.setValue('temperature', '98.6'); + + await device.refresh(); + + expect((await device.getAttribute('level'))?.value).toBe(7); + expect((await device.getAttribute('enabled'))?.value).toBe(true); + expect((await device.getAttribute('temperature'))?.value).toBeCloseTo(98.6); + }); + + it('setAttribute sends the correct serial command for each value type', async () => { + const device = (await provider.connectToDevice(makePortInfo())) as GenericSlvCtrlPlusDevice; + + expect(await device.setAttribute('level', Int.from(9))).toBe(9); + expect(simulator.getValue('level')).toBe('9'); + + expect(await device.setAttribute('enabled', true)).toBe(true); + expect(simulator.getValue('enabled')).toBe('1'); + + expect(await device.setAttribute('mode', 'auto')).toBe('auto'); + expect(simulator.getValue('mode')).toBe('auto'); + + expect(await device.setAttribute('intensity', Int.from(50))).toBe(50); + expect(simulator.getValue('intensity')).toBe('50'); + + expect(await device.setAttribute('preset', 'high')).toBe('high'); + expect(simulator.getValue('preset')).toBe('high'); + }); +}); From 2c24fc4ae81fa6996a90264a30c5a80310e96606 Mon Sep 17 00:00:00 2001 From: HRS Date: Thu, 11 Jun 2026 20:09:58 +0200 Subject: [PATCH 21/23] Revert unnecessary changes --- package-lock.json | 7 +++++-- package.json | 7 +++++-- src/util/async.ts | 13 +------------ 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index ab4cc460..74e2c5a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "@timesplinter/pimple": "^2.0.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", - "buttplug": "^3.2.1", + "buttplug": "^3.2.2", "class-transformer": "^0.5.1", "cors": "^2.8.5", "dotenv": "^17.2.0", @@ -38,10 +38,12 @@ "@babel/plugin-proposal-decorators": "^7.22.7", "@babel/preset-env": "^7.14.4", "@babel/preset-typescript": "^7.22.5", + "@serialport/binding-mock": "^10.2.2", "@types/cors": "^2.8.10", "@types/express": "^5.0.6", "@types/node": "^24.1.0", "@types/supertest": "^7.2.0", + "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.0.16", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", @@ -55,7 +57,8 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.52.0", "vitest": "^4.0.16", - "vitest-mock-extended": "^3.1.0" + "vitest-mock-extended": "^3.1.0", + "ws": "^8.21.0" }, "engines": { "node": ">=24.0.0 <25.0.0" diff --git a/package.json b/package.json index 10eaf73a..50ba37f2 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@timesplinter/pimple": "^2.0.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", - "buttplug": "^3.2.1", + "buttplug": "^3.2.2", "class-transformer": "^0.5.1", "cors": "^2.8.5", "dotenv": "^17.2.0", @@ -52,8 +52,11 @@ "supertest": "^7.2.2", "typescript": "^5.9.3", "typescript-eslint": "^8.52.0", + "@serialport/binding-mock": "^10.2.2", + "@types/ws": "^8.18.1", "vitest": "^4.0.16", - "vitest-mock-extended": "^3.1.0" + "vitest-mock-extended": "^3.1.0", + "ws": "^8.21.0" }, "engines": { "node": ">=24.0.0 <25.0.0" diff --git a/src/util/async.ts b/src/util/async.ts index 8640d52b..030f80e5 100644 --- a/src/util/async.ts +++ b/src/util/async.ts @@ -43,8 +43,7 @@ export const setIntervalAsync = ( let timer: ReturnType | undefined; const loop = async (): Promise => { - const fnPromise = fn(...args); - const promises: Promise[] = [fnPromise]; + const promises = [fn(...args)]; let timeoutHandle: ReturnType | undefined; @@ -60,16 +59,6 @@ export const setIntervalAsync = ( try { await Promise.race(promises); } catch (err) { - if (err instanceof IntervalTimeoutError) { - // The timeout won the race but the underlying work is still running. - // Attach a handler so the root cause gets reported when it eventually rejects. - fnPromise.catch(fnErr => { - if (options.onError) { - options.onError(fnErr); - } - }); - } - if (options.onError) { options.onError(err); } else { From cd3599f5d437ef5ecbeddd476b36821ba2bb109a Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 13 Jun 2026 19:32:23 +0200 Subject: [PATCH 22/23] wip --- package-lock.json | 6 +- package.json | 2 +- src/app.ts | 124 ++++---- src/index.ts | 17 +- src/serviceMap.ts | 8 - src/serviceProvider/serverServiceProvider.ts | 61 ---- src/util/expressUtils.ts | 4 +- tests/integration/api.spec.ts | 77 ++--- tests/integration/automationScripts.spec.ts | 50 ++-- tests/integration/buttplugIoDevice.spec.ts | 277 ++++++++++-------- tests/integration/deviceEvents.spec.ts | 35 ++- tests/integration/helpers/appHelper.ts | 47 +-- tests/integration/serialDevice.spec.ts | 160 ---------- tests/integration/slvCtrlSerialDevice.spec.ts | 183 ++++++++++++ 14 files changed, 529 insertions(+), 522 deletions(-) delete mode 100644 src/serviceProvider/serverServiceProvider.ts delete mode 100644 tests/integration/serialDevice.spec.ts create mode 100644 tests/integration/slvCtrlSerialDevice.spec.ts diff --git a/package-lock.json b/package-lock.json index 74e2c5a2..ec67bbb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "license": "LGPL-3.0-only", "dependencies": { "@sinclair/typebox": "^0.34.47", - "@timesplinter/pimple": "^2.0.0", + "@timesplinter/pimple": "^2.1.1", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "buttplug": "^3.2.2", @@ -2412,7 +2412,9 @@ "license": "MIT" }, "node_modules/@timesplinter/pimple": { - "version": "2.0.0", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@timesplinter/pimple/-/pimple-2.1.1.tgz", + "integrity": "sha512-Sy0nqk5480ZukdvEfx9mCmKMJ+jCnjiXePO7Flf+4RK5W5bs1qyGdExQJZ6tg6SoCKQekRvpaW6GNGl/Zf3WeA==", "license": "LGPL-3.0-or-later" }, "node_modules/@tybys/wasm-util": { diff --git a/package.json b/package.json index 50ba37f2..3d90fc18 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "dependencies": { "@sinclair/typebox": "^0.34.47", - "@timesplinter/pimple": "^2.0.0", + "@timesplinter/pimple": "^2.1.1", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "buttplug": "^3.2.2", diff --git a/src/app.ts b/src/app.ts index 0813439f..78d4fb74 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,7 +1,7 @@ import cors, { CorsOptions } from 'cors'; import contentTypeMiddleware from './middleware/contentTypeMiddleware.js'; import express from 'express'; -import { Pimple } from '@timesplinter/pimple'; +import { Container, Pimple } from '@timesplinter/pimple'; import ControllerServiceProvider from './serviceProvider/controllerServiceProvider.js'; import RepositoryServiceProvider from './serviceProvider/repositoryServiceProvider.js'; import SerializationServiceProvider from './serviceProvider/serializationServiceProvider.js'; @@ -14,7 +14,6 @@ import { DeviceUpdateData } from './socket/types.js'; import AutomationServiceProvider from './serviceProvider/automationServiceProvider.js'; import Device from './device/device.js'; import WebSocketEvent from './device/webSocketEvent.js'; -import ServerServiceProvider, { SslConfig } from './serviceProvider/serverServiceProvider.js'; import AutomationEventType from './automation/automationEventType.js'; import LoggerServiceProvider from './serviceProvider/loggerServiceProvider.js'; import DeviceDiscriminator from './serialization/discriminator/deviceDiscriminator.js'; @@ -26,22 +25,26 @@ import { DeviceManagerEvent } from './device/deviceManager.js'; import HealthServiceProvider from './serviceProvider/healthServiceProvider.js'; import { setIntervalAsync } from './util/async.js'; import { logError } from './util/error.js'; +import http from 'http' +import https from 'https' +import { Server } from 'socket.io'; +import fs from 'fs' +import BaseError from 'modern-errors'; -export type { SslConfig }; +export type SslConfig = { port: number, keyFile: string, certFile: string }; export interface AppOptions { - allowedOrigins?: string[]; - sslConfig?: SslConfig; + allowedOrigins: string[]; dataPath: string; } export interface AppInstance { - expressApp: express.Application; - container: Pimple; - listen: (httpPort: number, httpsPort?: number) => void; + instance: express.Application; + websocket: Server; + serve: (httpPort: number, sslConfig?: SslConfig) => void; } -const configureRoutes = (app: express.Application, container: Pimple): void => { +const configureRoutes = (app: express.Application, container: Container): void => { app.get('/devices', executeController(container, 'controller.getDevices')); app.get('/device/:deviceId', executeController(container, 'controller.getDevice')); app.patch('/device/:deviceId', executeController(container, 'controller.patchDevice')); @@ -64,13 +67,12 @@ const configureRoutes = (app: express.Application, container: Pimple app.get('/version', executeController(container, 'controller.version')); } -const configureWebsocket = (container: Pimple): void => { +const configureWebsocket = (io: Server, container: Container): void => { const deviceManager = container.get('device.manager'); const scriptRuntime = container.get('automation.scriptRuntime'); const settingsManager = container.get('settings.manager'); const serializer = container.get('serializer.classToPlain'); const logger = container.get('logger.default'); - const io = container.get('server.websocket'); const healthMetricsCollector = container.get('health.metricsCollector'); const deviceDiscriminator = DeviceDiscriminator.createClassTransformerTypeDiscriminator('type'); @@ -125,7 +127,7 @@ const configureWebsocket = (container: Pimple): void => { scriptRuntime.on(AutomationEventType.consoleLog, (data: unknown) => io.emit(AutomationEventType.consoleLog, data)); }; -const loadDeviceProviders = (container: Pimple): void => { +const loadDeviceProviders = (container: Container): void => { const serialPortObserver = container.get('device.observer.serial'); const logger = container.get('logger.default'); const settings = container.get('settings'); @@ -140,38 +142,36 @@ const loadDeviceProviders = (container: Pimple): void => { serialPortObserver.init().catch(e => logError(logger, `Initializing serial port observer failed`, e)); }; -export const createApp = (options: AppOptions): AppInstance => { - const { allowedOrigins = [], sslConfig, dataPath } = options; - - const corsOptions: CorsOptions = { - origin: (origin, callback) => { - if (undefined === origin || allowedOrigins.length === 0) { - return callback(null, true); - } - - return callback(null, allowedOrigins.includes(origin)); - }, - }; - +const buildCorsOptions = (allowedOrigins: string[]): CorsOptions => ({ + origin: (origin, callback): void => { + if (undefined === origin || allowedOrigins.length === 0) { + return callback(null, true); + } + return callback(null, allowedOrigins.includes(origin)); + }, +}); + +export const createContainer = (dataPath: string): Pimple => (new Pimple()) + .register(new LoggerServiceProvider()) + .register(new HealthServiceProvider()) + .register(new SettingsServiceProvider(dataPath)) + .register(new DeviceServiceProvider()) + .register(new ControllerServiceProvider()) + .register(new SocketServiceProvider()) + .register(new RepositoryServiceProvider(dataPath)) + .register(new SerializationServiceProvider()) + .register(new AutomationServiceProvider(dataPath)) + .register(new FactoryServiceProvider()) + .register(new SchemaValidationServiceProvider()) +; + +export const createApp = (container: Container, options: AppOptions): AppInstance => { + const corsOptions = buildCorsOptions(options.allowedOrigins); + const websocketServer = new Server(undefined, { + cors: corsOptions, + }); const app = express(); - const container = new Pimple(); - - container - .register(new LoggerServiceProvider()) - .register(new HealthServiceProvider()) - .register(new ServerServiceProvider(app, corsOptions, sslConfig)) - .register(new SettingsServiceProvider(dataPath)) - .register(new DeviceServiceProvider()) - .register(new ControllerServiceProvider()) - .register(new SocketServiceProvider()) - .register(new RepositoryServiceProvider(dataPath)) - .register(new SerializationServiceProvider()) - .register(new AutomationServiceProvider(dataPath)) - .register(new FactoryServiceProvider()) - .register(new SchemaValidationServiceProvider()) - ; - // Middlewares app .use((req, res, next) => { // Required for PNA preflight until https://github.com/expressjs/cors/pull/274 is merged @@ -187,29 +187,45 @@ export const createApp = (options: AppOptions): AppInstance => { .use(express.text()) ; - // Routes configureRoutes(app, container); - configureWebsocket(container); + configureWebsocket(websocketServer, container); loadDeviceProviders(container); return { - expressApp: app, - container, - listen: (httpPort: number, httpsPort?: number): void => { + instance: app, + websocket: websocketServer, + serve: (httpPort: number, sslConfig?: SslConfig): void => { + const logger = container.get('logger.default'); - const httpServer = container.get('server.http'); - const httpsServer = container.get('server.https'); + + const httpServer = http.createServer(app); + + websocketServer.attach(httpServer); httpServer.listen(httpPort, () => { - logger.info(`Node version: ${process.version}`); logger.info(`SlvCtrl+ server listening on http://localhost:${httpPort}`); }); - if (httpsServer !== undefined && httpsPort !== undefined) { - httpsServer.listen(httpsPort, () => { - logger.info(`SlvCtrl+ server listening on https://localhost:${httpsPort} (ssl)`); + if (sslConfig === undefined) { + return; + } + + try { + const key = fs.readFileSync(sslConfig.keyFile); + const cert = fs.readFileSync(sslConfig.certFile); + const httpsServer = https.createServer({ key, cert }, app); + + websocketServer.attach(httpsServer); + + httpsServer.listen(sslConfig.port, () => { + logger.info(`SlvCtrl+ server listening on https://localhost:${sslConfig.port} (ssl)`); }); + } catch (err) { + const baseError = BaseError.normalize(err); + logger.error(`Failed to load SSL certificates: ${baseError.message}`); + logger.warn('HTTPS server will not be started'); + return undefined; } } }; -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 398136e0..8a304441 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,6 @@ import 'dotenv/config'; import 'reflect-metadata'; -import { createApp } from './app.js'; -import { SslConfig } from './serviceProvider/serverServiceProvider.js'; +import { createApp, createContainer, SslConfig } from './app.js'; import { parseEnv } from './env.js'; const env = parseEnv(process.env); @@ -13,19 +12,17 @@ const allowedOrigins = undefined !== env.ALLOWED_ORIGINS && env.ALLOWED_ORIGINS. : []; const sslConfig: SslConfig | undefined = env.SSL_KEY_FILE !== undefined && env.SSL_CERT_FILE !== undefined - ? { keyFile: env.SSL_KEY_FILE, certFile: env.SSL_CERT_FILE } + ? { port: env.HTTPS_PORT, keyFile: env.SSL_KEY_FILE, certFile: env.SSL_CERT_FILE } : undefined; -const app = createApp({ - allowedOrigins, - sslConfig, - dataPath: env.DATA_PATH, -}); +const appOptions = { allowedOrigins, dataPath: env.DATA_PATH }; +const container = createContainer(env.DATA_PATH); +const app = createApp(container, appOptions); -const logger = app.container.get('logger.default'); +const logger = container.get('logger.default'); process.on('uncaughtException', (error: Error) => { logger.error('Asynchronous error caught', error); }); -app.listen(env.PORT, env.HTTPS_PORT); +app.serve(env.PORT, sslConfig); diff --git a/src/serviceMap.ts b/src/serviceMap.ts index c06321ec..28852562 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -1,7 +1,4 @@ import { Ajv } from 'ajv'; -import type http from 'http'; -import type https from 'https'; -import { Server } from 'socket.io'; import ClassToPlainSerializer from './serialization/classToPlainSerializer.js'; import PlainToClassSerializer from './serialization/plainToClassSerializer.js'; import Logger from './logging/Logger.js'; @@ -57,11 +54,6 @@ type ServiceMap = { /* loggerServiceProvider */ 'logger.default': Logger, - /* serverServiceProvider */ - 'server.http': http.Server, - 'server.https': https.Server | undefined, - 'server.websocket': Server, - /* deviceServiceProvider */ 'device.manager': DeviceManager, 'device.serial.transport.factory': SerialDeviceTransportFactory, diff --git a/src/serviceProvider/serverServiceProvider.ts b/src/serviceProvider/serverServiceProvider.ts deleted file mode 100644 index 33275e29..00000000 --- a/src/serviceProvider/serverServiceProvider.ts +++ /dev/null @@ -1,61 +0,0 @@ -import BaseError from 'modern-errors'; -import { Pimple, ServiceProvider } from '@timesplinter/pimple'; -import http from 'http' -import https from 'https' -import fs from 'fs' -import { Server } from 'socket.io'; -import ServiceMap from '../serviceMap.js'; -import { CorsOptions } from 'cors'; -import express from 'express'; - -export type SslConfig = { keyFile: string, certFile: string }; - -export default class ServerServiceProvider implements ServiceProvider -{ - private readonly app: express.Application; - private readonly corsOptions: CorsOptions; - private readonly sslConfig?: SslConfig; - - public constructor(app: express.Application, corsOptions: CorsOptions, sslConfig?: SslConfig) { - this.app = app; - this.corsOptions = corsOptions; - this.sslConfig = sslConfig; - } - - public register(container: Pimple): void { - container.set('server.websocket', () => new Server(undefined, { - cors: this.corsOptions - })); - - container.set('server.http', () => { - const server = http.createServer(this.app); - - container.get('server.websocket').attach(server); - - return server; - }); - - container.set('server.https', () => { - if (this.sslConfig === undefined) { - return undefined; - } - - const logger = container.get('logger.default'); - - try { - const key = fs.readFileSync(this.sslConfig.keyFile); - const cert = fs.readFileSync(this.sslConfig.certFile); - const server = https.createServer({ key, cert }, this.app); - - container.get('server.websocket').attach(server); - - return server; - } catch (err) { - const baseError = BaseError.normalize(err); - logger.error(`Failed to load SSL certificates: ${baseError.message}`); - logger.warn('HTTPS server will not be started'); - return undefined; - } - }); - } -} diff --git a/src/util/expressUtils.ts b/src/util/expressUtils.ts index a77fa468..bf556c9f 100644 --- a/src/util/expressUtils.ts +++ b/src/util/expressUtils.ts @@ -1,5 +1,5 @@ import type { Request, Response } from 'express'; -import type { Pimple } from '@timesplinter/pimple'; +import type { Container } from '@timesplinter/pimple'; import type ServiceMap from '../serviceMap.js'; export type ControllerKey = { @@ -7,7 +7,7 @@ export type ControllerKey = { }[keyof ServiceMap]; export const executeController = ( - container: Pimple, + container: Container, controllerName: ControllerKey ): (req: Request, res: Response) => void | Promise => { return (req: Request, res: Response) => container.get(controllerName).execute(req, res); diff --git a/tests/integration/api.spec.ts b/tests/integration/api.spec.ts index 41efd0ad..81e2cd8f 100644 --- a/tests/integration/api.spec.ts +++ b/tests/integration/api.spec.ts @@ -1,27 +1,30 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import request from 'supertest'; -import { AppInstance } from '../../src/app.js'; import { TEST_DEVICE_ID, createTestApp, teardownTestApp, resetTestApp, connectDevices } from './helpers/appHelper.js'; +import ServiceMap from '../../src/serviceMap.js'; +import { Container } from '@timesplinter/pimple'; +import { AppInstance } from '../../src/app.js'; describe('REST API', () => { - let instance: AppInstance; + let app: AppInstance; + let container: Container; let tmpDir: string; beforeAll(async () => { - ({ instance, tmpDir } = await createTestApp()); + ({ app, container, tmpDir } = await createTestApp()); }); afterAll(async () => { - await teardownTestApp(instance, tmpDir); + await teardownTestApp(app, container, tmpDir); }); beforeEach(async () => { - await resetTestApp(instance); + await resetTestApp(app, container); }); describe('GET /version', () => { it('returns version string', async () => { - const res = await request(instance.expressApp).get('/version'); + const res = await request(app.instance).get('/version'); expect(res.status).toBe(200); expect(res.body).toHaveProperty('version'); @@ -31,7 +34,7 @@ describe('REST API', () => { describe('GET /health', () => { it('returns 200 with metrics or 204 when not yet collected', async () => { - const res = await request(instance.expressApp).get('/health'); + const res = await request(app.instance).get('/health'); expect(res.status).toBeOneOf([200, 204]); }); @@ -41,9 +44,9 @@ describe('REST API', () => { it('returns connected devices', async () => { const testDeviceName = 'Test Random Generator'; - await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: testDeviceName }]); + await connectDevices(container, [{ id: TEST_DEVICE_ID, name: testDeviceName }]); - const res = await request(instance.expressApp).get('/devices'); + const res = await request(app.instance).get('/devices'); expect(res.status).toBe(200); expect(res.body.count).toBe(1); @@ -69,7 +72,7 @@ describe('REST API', () => { }); it('returns empty list when no devices connected', async () => { - const res = await request(instance.expressApp).get('/devices'); + const res = await request(app.instance).get('/devices'); expect(res.status).toBe(200); expect(res.body.count).toBe(0); @@ -79,9 +82,9 @@ describe('REST API', () => { describe('GET /device/:deviceId', () => { it('returns a single connected device', async () => { - await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + await connectDevices(container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); - const res = await request(instance.expressApp).get(`/device/${TEST_DEVICE_ID}`); + const res = await request(app.instance).get(`/device/${TEST_DEVICE_ID}`); expect(res.status).toBe(200); expect(res.body).toEqual(expect.objectContaining({ @@ -91,7 +94,7 @@ describe('REST API', () => { }); it('returns 404 for unknown device', async () => { - const res = await request(instance.expressApp).get('/device/unknown-id'); + const res = await request(app.instance).get('/device/unknown-id'); expect(res.status).toBe(404); }); @@ -99,7 +102,7 @@ describe('REST API', () => { describe('PATCH /device/:deviceId', () => { it('returns 404 for unknown device', async () => { - const res = await request(instance.expressApp) + const res = await request(app.instance) .patch('/device/unknown-id') .send({}); @@ -107,9 +110,9 @@ describe('REST API', () => { }); it('returns 202 for connected device', async () => { - await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + await connectDevices(container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); - const res = await request(instance.expressApp) + const res = await request(app.instance) .patch(`/device/${TEST_DEVICE_ID}`) .send({}); @@ -119,7 +122,7 @@ describe('REST API', () => { describe('GET /settings', () => { it('returns current settings as JSON', async () => { - const res = await request(instance.expressApp).get('/settings'); + const res = await request(app.instance).get('/settings'); expect(res.status).toBe(200); expect(res.headers['content-type']).toMatch(/application\/json/); @@ -130,10 +133,10 @@ describe('REST API', () => { describe('PUT /settings', () => { it('accepts valid settings and returns them', async () => { - const getRes = await request(instance.expressApp).get('/settings'); + const getRes = await request(app.instance).get('/settings'); const currentSettings = getRes.body; - const res = await request(instance.expressApp) + const res = await request(app.instance) .put('/settings') .send(currentSettings); @@ -144,7 +147,7 @@ describe('REST API', () => { }); it('returns 400 for invalid settings', async () => { - const res = await request(instance.expressApp) + const res = await request(app.instance) .put('/settings') .send({ invalid: true }); @@ -156,7 +159,7 @@ describe('REST API', () => { describe('GET /automation/scripts', () => { it('returns empty list when no scripts exist', async () => { - const res = await request(instance.expressApp).get('/automation/scripts'); + const res = await request(app.instance).get('/automation/scripts'); expect(res.status).toBe(200); expect(res.body.count).toBe(0); @@ -164,12 +167,12 @@ describe('REST API', () => { }); it('lists saved scripts', async () => { - await request(instance.expressApp) + await request(app.instance) .post('/automation/scripts/test.js') .set('Content-Type', 'text/plain') .send('onEvent(() => {});'); - const res = await request(instance.expressApp).get('/automation/scripts'); + const res = await request(app.instance).get('/automation/scripts'); expect(res.status).toBe(200); expect(res.body.count).toBe(1); @@ -181,7 +184,7 @@ describe('REST API', () => { it('creates a script and returns its content', async () => { const script = 'onEvent(() => {});'; - const res = await request(instance.expressApp) + const res = await request(app.instance) .post('/automation/scripts/my-script.js') .set('Content-Type', 'text/plain') .send(script); @@ -191,7 +194,7 @@ describe('REST API', () => { }); it('returns 400 for invalid filename', async () => { - const res = await request(instance.expressApp) + const res = await request(app.instance) .post('/automation/scripts/INVALID_NAME.js') .set('Content-Type', 'text/plain') .send('onEvent(() => {});'); @@ -200,7 +203,7 @@ describe('REST API', () => { }); it('returns 400 for wrong content type', async () => { - const res = await request(instance.expressApp) + const res = await request(app.instance) .post('/automation/scripts/test.js') .set('Content-Type', 'application/json') .send(JSON.stringify({ script: 'onEvent(() => {});' })); @@ -213,12 +216,12 @@ describe('REST API', () => { it('returns script content as plain text', async () => { const script = 'onEvent(() => {});'; - await request(instance.expressApp) + await request(app.instance) .post('/automation/scripts/read-test.js') .set('Content-Type', 'text/plain') .send(script); - const res = await request(instance.expressApp).get('/automation/scripts/read-test.js'); + const res = await request(app.instance).get('/automation/scripts/read-test.js'); expect(res.status).toBe(200); expect(res.headers['content-type']).toMatch(/text\/plain/); @@ -226,13 +229,13 @@ describe('REST API', () => { }); it('returns 404 for non-existent script', async () => { - const res = await request(instance.expressApp).get('/automation/scripts/nonexistent.js'); + const res = await request(app.instance).get('/automation/scripts/nonexistent.js'); expect(res.status).toBe(404); }); it('returns 400 for invalid filename', async () => { - const res = await request(instance.expressApp).get('/automation/scripts/INVALID_NAME.js'); + const res = await request(app.instance).get('/automation/scripts/INVALID_NAME.js'); expect(res.status).toBe(400); }); @@ -240,21 +243,21 @@ describe('REST API', () => { describe('DELETE /automation/scripts/:fileName', () => { it('deletes a script and returns 204', async () => { - await request(instance.expressApp) + await request(app.instance) .post('/automation/scripts/to-delete.js') .set('Content-Type', 'text/plain') .send('onEvent(() => {});'); - const res = await request(instance.expressApp).delete('/automation/scripts/to-delete.js'); + const res = await request(app.instance).delete('/automation/scripts/to-delete.js'); expect(res.status).toBe(204); - const getRes = await request(instance.expressApp).get('/automation/scripts/to-delete.js'); + const getRes = await request(app.instance).get('/automation/scripts/to-delete.js'); expect(getRes.status).toBe(404); }); it('returns 400 for invalid filename', async () => { - const res = await request(instance.expressApp).delete('/automation/scripts/INVALID_NAME.js'); + const res = await request(app.instance).delete('/automation/scripts/INVALID_NAME.js'); expect(res.status).toBe(400); }); @@ -262,12 +265,12 @@ describe('REST API', () => { describe('GET /automation/log', () => { it('returns log as plain text after a script has run', async () => { - await request(instance.expressApp) + await request(app.instance) .post('/automation/run') .set('Content-Type', 'text/plain') .send('onEvent(() => {});'); - const res = await request(instance.expressApp).get('/automation/log'); + const res = await request(app.instance).get('/automation/log'); expect(res.status).toBe(200); expect(res.headers['content-type']).toMatch(/text\/plain/); @@ -276,7 +279,7 @@ describe('REST API', () => { describe('GET /automation/status', () => { it('returns not running when no script is loaded', async () => { - const res = await request(instance.expressApp).get('/automation/status'); + const res = await request(app.instance).get('/automation/status'); expect(res.status).toBe(200); expect(res.body.running).toBe(false); diff --git a/tests/integration/automationScripts.spec.ts b/tests/integration/automationScripts.spec.ts index f1898682..a19b9d10 100644 --- a/tests/integration/automationScripts.spec.ts +++ b/tests/integration/automationScripts.spec.ts @@ -10,6 +10,9 @@ import { resetTestApp, connectDevices, } from './helpers/appHelper.js'; +import ServiceMap from '../../src/serviceMap.js'; +import { Container } from '@timesplinter/pimple'; +import { appendFileSync } from 'fs'; const collectUntilMarker = ( scriptRuntime: ScriptRuntime, @@ -53,28 +56,29 @@ const waitForEvent = (scriptRuntime: ScriptRuntime, eventType: AutomationEventTy }; describe('Automation scripts', () => { - let instance: AppInstance; + let app: AppInstance; let tmpDir: string; + let container: Container; beforeAll(async () => { - ({ instance, tmpDir } = await createTestApp()); + ({ app, container, tmpDir } = await createTestApp()); }); afterAll(async () => { - await teardownTestApp(instance, tmpDir); + await teardownTestApp(app, container, tmpDir); }); beforeEach(async () => { - await resetTestApp(instance); + await resetTestApp(app, container); }, 2000); describe('Script lifecycle via REST API', () => { it('POST /automation/run loads the script and reports running status', async () => { - const scriptRuntime = instance.container.get('automation.scriptRuntime'); + const scriptRuntime = container.get('automation.scriptRuntime'); const scriptStarted = waitForEvent(scriptRuntime, AutomationEventType.scriptStarted); - const res = await request(instance.expressApp) + const res = await request(app.instance) .post('/automation/run') .set('Content-Type', 'text/plain') .send('onEvent(() => {});'); @@ -85,15 +89,15 @@ describe('Automation scripts', () => { await scriptStarted; - const statusRes = await request(instance.expressApp).get('/automation/status'); + const statusRes = await request(app.instance).get('/automation/status'); expect(statusRes.status).toBe(200); expect(statusRes.body.running).toBe(true); }); it('POST /automation/run returns 400 for non-text/plain content type', async () => { - const scriptRuntime = instance.container.get('automation.scriptRuntime'); + const scriptRuntime = container.get('automation.scriptRuntime'); - const res = await request(instance.expressApp) + const res = await request(app.instance) .post('/automation/run') .set('Content-Type', 'application/json') .send(JSON.stringify({ script: 'onEvent(() => {});' })); @@ -104,27 +108,27 @@ describe('Automation scripts', () => { }); it('GET /automation/stop stops the running script and emits scriptStopped', async () => { - const scriptRuntime = instance.container.get('automation.scriptRuntime'); + const scriptRuntime = container.get('automation.scriptRuntime'); - await request(instance.expressApp) + await request(app.instance) .post('/automation/run') .set('Content-Type', 'text/plain') .send('onEvent(() => {});'); const scriptStopped = waitForEvent(scriptRuntime, AutomationEventType.scriptStopped); - const stopRes = await request(instance.expressApp).get('/automation/stop'); + const stopRes = await request(app.instance).get('/automation/stop'); expect(stopRes.status).toBe(200); await scriptStopped; - const statusRes = await request(instance.expressApp).get('/automation/status'); + const statusRes = await request(app.instance).get('/automation/status'); expect(statusRes.status).toBe(200); expect(statusRes.body.running).toBe(false); }); it('GET /automation/status returns not running when no script is loaded', async () => { - const res = await request(instance.expressApp).get('/automation/status'); + const res = await request(app.instance).get('/automation/status'); expect(res.status).toBe(200); expect(res.body.running).toBe(false); @@ -134,7 +138,7 @@ describe('Automation scripts', () => { describe('Script reacts to device events', () => { it('onEvent is called with deviceConnected when a real device connects', async () => { - const scriptRuntime = instance.container.get('automation.scriptRuntime'); + const scriptRuntime = container.get('automation.scriptRuntime'); const MARKER = 'connect-done'; const logsPromise = collectUntilMarker(scriptRuntime, MARKER); @@ -148,7 +152,7 @@ describe('Automation scripts', () => { }); `); - await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + await connectDevices(container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); const logs = await logsPromise; @@ -159,10 +163,10 @@ describe('Automation scripts', () => { }); it('onEvent receives the real attribute value on deviceRefreshed', async () => { - const scriptRuntime = instance.container.get('automation.scriptRuntime'); + const scriptRuntime = container.get('automation.scriptRuntime'); const MARKER = 'refresh-done'; - await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + await connectDevices(container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); await scriptRuntime.load(` onEvent(async (event) => { @@ -186,10 +190,10 @@ describe('Automation scripts', () => { }); it('onEvent is called with deviceDisconnected when a real device disconnects', async () => { - const scriptRuntime = instance.container.get('automation.scriptRuntime'); + const scriptRuntime = container.get('automation.scriptRuntime'); const MARKER = 'disconnect-done'; - await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + await connectDevices(container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); const logsPromise = collectUntilMarker(scriptRuntime, MARKER); @@ -201,7 +205,7 @@ describe('Automation scripts', () => { }); `); - const device = instance.container.get('device.manager').getConnectedDevices()[0]; + const device = container.get('device.manager').getConnectedDevices()[0]; await device.close(); const logs = await logsPromise; @@ -209,12 +213,12 @@ describe('Automation scripts', () => { }); it('onStart runs once when script is loaded via API, before any device events', async () => { - const scriptRuntime = instance.container.get('automation.scriptRuntime'); + const scriptRuntime = container.get('automation.scriptRuntime'); const MARKER = 'start-done'; const logsPromise = collectUntilMarker(scriptRuntime, MARKER); - await request(instance.expressApp) + await request(app.instance) .post('/automation/run') .set('Content-Type', 'text/plain') .send(` diff --git a/tests/integration/buttplugIoDevice.spec.ts b/tests/integration/buttplugIoDevice.spec.ts index eee73cd5..ac36733a 100644 --- a/tests/integration/buttplugIoDevice.spec.ts +++ b/tests/integration/buttplugIoDevice.spec.ts @@ -1,35 +1,45 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import EventEmitter from 'events'; -import { DeviceEvent } from '../../src/device/device.js'; -import Settings from '../../src/settings/settings.js'; -import DeviceManager, { DeviceManagerEvent } from '../../src/device/deviceManager.js'; -import ButtplugIoWebsocketDeviceProvider from '../../src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; -import ButtplugIoDeviceFactory from '../../src/device/protocol/buttplugIo/buttplugIoDeviceFactory.js'; -import UuidFactory from '../../src/factory/uuidFactory.js'; -import DateFactory from '../../src/factory/dateFactory.js'; -import EventEmitterFactory from '../../src/factory/eventEmitterFactory.js'; -import Logger from '../../src/logging/Logger.js'; -import { ButtplugIoServerSimulator } from './helpers/buttplugIoServerSimulator.js'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import { AppInstance } from '../../src/app.js'; +import Device, { DeviceEvent } from '../../src/device/device.js'; +import { DeviceManagerEvent } from '../../src/device/deviceManager.js'; import ButtplugIoDevice from '../../src/device/protocol/buttplugIo/buttplugIoDevice.js'; +import ButtplugIoWebsocketDeviceProvider from '../../src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; +import WebSocketEvent from '../../src/device/webSocketEvent.js'; import IntRangeDeviceAttribute from '../../src/device/attribute/intRangeDeviceAttribute.js'; import BoolDeviceAttribute from '../../src/device/attribute/boolDeviceAttribute.js'; import IntDeviceAttribute from '../../src/device/attribute/intDeviceAttribute.js'; import { DeviceAttributeModifier } from '../../src/device/attribute/deviceAttribute.js'; -import Device from '../../src/device/device.js'; import { Int } from '../../src/util/numbers.js'; +import { ButtplugIoServerSimulator } from './helpers/buttplugIoServerSimulator.js'; +import { createTestApp, teardownTestApp } from './helpers/appHelper.js'; +import ServiceMap from '../../src/serviceMap.js'; +import { Container } from '@timesplinter/pimple'; + +process.env.LOG_LEVEL = process.env.LOG_LEVEL ?? 'silent'; + +const BUTTPLUG_SOURCE_ID = 'd5e6f7a8-5678-4321-abcd-ef1234567894'; + +function makeButtplugSettings(port: number): object { + return { + knownDevices: {}, + deviceSources: { + [BUTTPLUG_SOURCE_ID]: { + id: BUTTPLUG_SOURCE_ID, + type: ButtplugIoWebsocketDeviceProvider.providerName, + config: { + address: `127.0.0.1:${port}`, + autoScan: false, + useDeviceNameAsId: true, + }, + }, + }, + }; +} -const noopLogger: Logger = { - child: () => noopLogger, - trace: () => {}, - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - fatal: () => {}, -}; - -function waitForDeviceConnected(deviceManager: DeviceManager, timeoutMs = 3000): Promise { +function waitForDeviceConnected(container: Container, timeoutMs = 5000): Promise { return new Promise((resolve, reject) => { + const deviceManager = container.get('device.manager'); const timeout = setTimeout(() => { deviceManager.off(DeviceManagerEvent.deviceConnected, listener); reject(new Error(`Timed out waiting for device to connect (>${timeoutMs}ms)`)); @@ -45,144 +55,151 @@ function waitForDeviceConnected(deviceManager: DeviceManager, timeoutMs = 3000): }); } -describe('ButtplugIO websocket device', () => { +describe('ButtplugIo websocket provider', () => { let simulator: ButtplugIoServerSimulator; - let provider: ButtplugIoWebsocketDeviceProvider; - let deviceManager: DeviceManager; + let app: AppInstance; + let container: Container; + let tmpDir: string; + let wsEmitSpy: ReturnType; - beforeEach(async () => { + beforeAll(async () => { simulator = new ButtplugIoServerSimulator(); - const port = await simulator.start(); - - const deviceFactory = new ButtplugIoDeviceFactory( - new UuidFactory(), - new DateFactory(), - new EventEmitterFactory(), - new Settings(), - noopLogger, - ); - - deviceManager = new DeviceManager(new EventEmitter(), new Map(), noopLogger); - - provider = new ButtplugIoWebsocketDeviceProvider( - deviceManager, - new EventEmitter(), - deviceFactory, - `127.0.0.1:${port}`, - false, // autoScan - true, // useDeviceNameAsId - noopLogger, - ); - }); - - afterEach(async () => { - await provider.stop(); - await deviceManager.reset(); - await simulator.stop(); - }); + const simulatorPort = await simulator.start(); - it('connects and parses all attribute types from the device list', async () => { - // Single device with all three attribute mapping variants: - // actuator stepCount > 2 → IntRangeDeviceAttribute (write-only) - // actuator stepCount <= 2 → BoolDeviceAttribute (write-only) - // sensor with sensorRange → IntRangeDeviceAttribute (read-only) simulator.addDevice({ - name: 'MockDevice', - actuators: [ - { featureDescriptor: 'Vibrator', actuatorType: 'Vibrate', stepCount: 20 }, - { featureDescriptor: 'Switch', actuatorType: 'Oscillate', stepCount: 2 }, - ], + name: 'TestVibe', + actuators: [{ featureDescriptor: 'Vibrator', actuatorType: 'Vibrate', stepCount: 20 }], sensors: [{ featureDescriptor: 'Pressure', sensorType: 'Pressure', sensorRange: [0, 100] }], }); - const deviceConnected = waitForDeviceConnected(deviceManager); - await provider.init(); - const device = await deviceConnected; + ({ app, container, tmpDir } = await createTestApp(makeButtplugSettings(simulatorPort))); - expect(device).toBeInstanceOf(ButtplugIoDevice); + wsEmitSpy = vi.spyOn(app.websocket, 'emit'); - const vibe = await device.getAttribute('Vibrate-0') as IntRangeDeviceAttribute; - expect(vibe).toBeInstanceOf(IntRangeDeviceAttribute); - expect(vibe.modifier).toBe(DeviceAttributeModifier.writeOnly); - expect(vibe.min).toBe(0); - expect(vibe.max).toBe(20); + await waitForDeviceConnected(container); + }); - const oscillate = await device.getAttribute('Oscillate-1'); - expect(oscillate).toBeInstanceOf(BoolDeviceAttribute); - expect(oscillate?.modifier).toBe(DeviceAttributeModifier.writeOnly); + afterAll(async () => { + await teardownTestApp(app, container, tmpDir); + await simulator.stop(); + }); - const pressure = await device.getAttribute('Pressure-0') as IntRangeDeviceAttribute; - expect(pressure).toBeInstanceOf(IntRangeDeviceAttribute); - expect(pressure.modifier).toBe(DeviceAttributeModifier.readOnly); - expect(pressure.min).toBe(0); - expect(pressure.max).toBe(100); + it('registers the device in the device manager', () => { + const devices = container.get('device.manager').getConnectedDevices(); + expect(devices).toHaveLength(1); + expect(devices[0]).toBeInstanceOf(ButtplugIoDevice); }); - it('picks up a device added via push after initial connection', async () => { - await provider.init(); - await new Promise(resolve => setTimeout(resolve, 200)); + it('exposes the device via GET /devices', async () => { + const res = await request(app.instance).get('/devices'); + expect(res.status).toBe(200); + expect(res.body.count).toBe(1); + expect(res.body.items[0]).toEqual(expect.objectContaining({ + provider: ButtplugIoWebsocketDeviceProvider.providerName, + type: 'buttplugIo', + })); + }); - const deviceConnected = waitForDeviceConnected(deviceManager); - simulator.addDevice({ - name: 'LateDevice', - actuators: [{ featureDescriptor: 'Vibrator', actuatorType: 'Vibrate', stepCount: 10 }], + it('emits deviceConnected WebSocket event when the device connects', () => { + expect(wsEmitSpy).toHaveBeenCalledWith( + WebSocketEvent.deviceConnected, + expect.objectContaining({ provider: ButtplugIoWebsocketDeviceProvider.providerName }), + ); + }); + + describe('device protocol', () => { + it('connects and parses all attribute types from the device list', async () => { + const deviceConnected = waitForDeviceConnected(container); + simulator.addDevice({ + name: 'MockDevice', + actuators: [ + { featureDescriptor: 'Vibrator', actuatorType: 'Vibrate', stepCount: 20 }, + { featureDescriptor: 'Switch', actuatorType: 'Oscillate', stepCount: 2 }, + ], + sensors: [{ featureDescriptor: 'Pressure', sensorType: 'Pressure', sensorRange: [0, 100] }], + }); + const device = await deviceConnected; + + expect(device).toBeInstanceOf(ButtplugIoDevice); + + const vibe = await device.getAttribute('Vibrate-0') as IntRangeDeviceAttribute; + expect(vibe).toBeInstanceOf(IntRangeDeviceAttribute); + expect(vibe.modifier).toBe(DeviceAttributeModifier.writeOnly); + expect(vibe.min).toBe(0); + expect(vibe.max).toBe(20); + + const oscillate = await device.getAttribute('Oscillate-1'); + expect(oscillate).toBeInstanceOf(BoolDeviceAttribute); + expect(oscillate?.modifier).toBe(DeviceAttributeModifier.writeOnly); + + const pressure = await device.getAttribute('Pressure-0') as IntRangeDeviceAttribute; + expect(pressure).toBeInstanceOf(IntRangeDeviceAttribute); + expect(pressure.modifier).toBe(DeviceAttributeModifier.readOnly); + expect(pressure.min).toBe(0); + expect(pressure.max).toBe(100); }); - expect(await deviceConnected).toBeInstanceOf(ButtplugIoDevice); - }); + it('picks up a device added via push after initial connection', async () => { + await new Promise(resolve => setTimeout(resolve, 200)); - it('setAttribute sends a ScalarCmd with the correct normalised scalar value', async () => { - simulator.addDevice({ - name: 'MockVibe', - actuators: [{ featureDescriptor: 'Vibrator', actuatorType: 'Vibrate', stepCount: 20 }], + const deviceConnected = waitForDeviceConnected(container); + simulator.addDevice({ + name: 'LateDevice', + actuators: [{ featureDescriptor: 'Vibrator', actuatorType: 'Vibrate', stepCount: 10 }], + }); + + expect(await deviceConnected).toBeInstanceOf(ButtplugIoDevice); }); - const deviceConnected = waitForDeviceConnected(deviceManager); - await provider.init(); - const device = await deviceConnected; + it('setAttribute sends a ScalarCmd with the correct normalised scalar value', async () => { + simulator.receivedScalarCmds = []; - await device.setAttribute('Vibrate-0', Int.from(10)); + const deviceConnected = waitForDeviceConnected(container); + simulator.addDevice({ + name: 'MockVibe', + actuators: [{ featureDescriptor: 'Vibrator', actuatorType: 'Vibrate', stepCount: 20 }], + }); + const device = await deviceConnected; - expect(simulator.receivedScalarCmds).toHaveLength(1); - const cmd = simulator.receivedScalarCmds[0]; - expect(cmd?.actuatorType).toBe('Vibrate'); - expect(cmd?.index).toBe(0); - expect(cmd?.scalar).toBeCloseTo(0.5); // 10 / 20 - }); + await device.setAttribute('Vibrate-0', Int.from(10)); - it('polls sensor values automatically at the configured 100ms interval', async () => { - const deviceIndex = simulator.addDevice({ - name: 'MockSensor', - sensors: [{ featureDescriptor: 'Pressure', sensorType: 'Pressure', sensorRange: [0, 100], reading: 77 }], + expect(simulator.receivedScalarCmds).toHaveLength(1); + const cmd = simulator.receivedScalarCmds[0]; + expect(cmd?.actuatorType).toBe('Vibrate'); + expect(cmd?.index).toBe(0); + expect(cmd?.scalar).toBeCloseTo(0.5); }); - const deviceConnected = waitForDeviceConnected(deviceManager); - await provider.init(); - const device = await deviceConnected; + it('polls sensor values automatically at the configured 100ms interval', async () => { + const deviceIndex = simulator.addDevice({ + name: 'MockSensor', + sensors: [{ featureDescriptor: 'Pressure', sensorType: 'Pressure', sensorRange: [0, 100], reading: 77 }], + }); - simulator.setSensorReading(deviceIndex, 0, 77); + const device = await waitForDeviceConnected(container); - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('Timed out waiting for device refresh')), 1000); - device.on(DeviceEvent.deviceRefreshed, () => { clearTimeout(timeout); resolve(); }); - }); + simulator.setSensorReading(deviceIndex, 0, 77); - expect((await device.getAttribute('Pressure-0'))?.value).toBe(77); - }); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for device refresh')), 1000); + device.on(DeviceEvent.deviceRefreshed, () => { clearTimeout(timeout); resolve(); }); + }); - it('refresh reads the current sensor value from the server', async () => { - const deviceIndex = simulator.addDevice({ - name: 'MockSensor', - sensors: [{ featureDescriptor: 'Pressure', sensorType: 'Pressure', sensorRange: [0, 100], reading: 42 }], + expect((await device.getAttribute('Pressure-0'))?.value).toBe(77); }); - const deviceConnected = waitForDeviceConnected(deviceManager); - await provider.init(); - const device = await deviceConnected; + it('refresh reads the current sensor value from the server', async () => { + const deviceIndex = simulator.addDevice({ + name: 'MockSensor', + sensors: [{ featureDescriptor: 'Pressure', sensorType: 'Pressure', sensorRange: [0, 100], reading: 42 }], + }); + + const device = await waitForDeviceConnected(container); - simulator.setSensorReading(deviceIndex, 0, 42); - await device.refresh(); + simulator.setSensorReading(deviceIndex, 0, 42); + await device.refresh(); - expect((await device.getAttribute('Pressure-0') as IntDeviceAttribute).value).toBe(42); + expect((await device.getAttribute('Pressure-0') as IntDeviceAttribute).value).toBe(42); + }); }); }); diff --git a/tests/integration/deviceEvents.spec.ts b/tests/integration/deviceEvents.spec.ts index 3ef395a0..49b7c890 100644 --- a/tests/integration/deviceEvents.spec.ts +++ b/tests/integration/deviceEvents.spec.ts @@ -16,36 +16,39 @@ import { resetTestApp, connectDevices, } from './helpers/appHelper.js'; +import ServiceMap from '../../src/serviceMap.js'; +import { Container } from '@timesplinter/pimple'; describe('Device events', () => { - let instance: AppInstance; + let app: AppInstance; let tmpDir: string; + let container: Container; beforeAll(async () => { - ({ instance, tmpDir } = await createTestApp()); + ({ app, container, tmpDir } = await createTestApp()); }); afterAll(async () => { - await teardownTestApp(instance, tmpDir); + await teardownTestApp(app, container, tmpDir); }); beforeEach(async () => { - await resetTestApp(instance); + await resetTestApp(app, container); }); it('virtual device connected', async () => { - await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + await connectDevices(container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); - const devices = instance.container.get('device.manager').getConnectedDevices(); + const devices = container.get('device.manager').getConnectedDevices(); expect(devices).toHaveLength(1); expect(devices[0].getDeviceId).toBe(TEST_DEVICE_ID); }); it('virtual device gets refreshed', async () => { - await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + await connectDevices(container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); - const deviceManager = instance.container.get('device.manager'); + const deviceManager = container.get('device.manager'); const device = deviceManager.getConnectedDevices()[0] as VirtualDevice; let observedValue: number | undefined; @@ -77,13 +80,13 @@ describe('Device events', () => { }); it('dynamically detects a new virtual device added to settings', async () => { - await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + await connectDevices(container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); - const deviceManager = instance.container.get('device.manager'); + const deviceManager = container.get('device.manager'); expect(deviceManager.getConnectedDevices()).toHaveLength(1); - await connectDevices(instance, [{ id: NEW_DEVICE_ID, name: 'Test Device 2', config: { min: 0, max: 50 } }]); + await connectDevices(container, [{ id: NEW_DEVICE_ID, name: 'Test Device 2', config: { min: 0, max: 50 } }]); const devices = deviceManager.getConnectedDevices(); @@ -93,13 +96,13 @@ describe('Device events', () => { }, 1000); it('dynamically removes a virtual device deleted from settings', async () => { - await connectDevices(instance, [ + await connectDevices(container, [ { id: TEST_DEVICE_ID, name: 'Test Random Generator' }, { id: NEW_DEVICE_ID, name: 'Test Device 2', config: { min: 0, max: 50 } }, ]); - const deviceManager = instance.container.get('device.manager'); - const settingsManager = instance.container.get('settings.manager'); + const deviceManager = container.get('device.manager'); + const settingsManager = container.get('settings.manager'); const deviceDisconnected = new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Timed out waiting for device to disconnect')), 1000); @@ -123,9 +126,9 @@ describe('Device events', () => { }, 1000); it('virtual device disconnected', async () => { - await connectDevices(instance, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + await connectDevices(container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); - const deviceManager = instance.container.get('device.manager'); + const deviceManager = container.get('device.manager'); const device = deviceManager.getConnectedDevices()[0]; const disconnected = new Promise((resolve, reject) => { diff --git a/tests/integration/helpers/appHelper.ts b/tests/integration/helpers/appHelper.ts index 64bdf423..9cc06df9 100644 --- a/tests/integration/helpers/appHelper.ts +++ b/tests/integration/helpers/appHelper.ts @@ -1,11 +1,15 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import { createApp, AppInstance } from '../../../src/app.js'; +import { createApp, AppInstance, createContainer, AppOptions } from '../../../src/app.js'; import { DeviceManagerEvent } from '../../../src/device/deviceManager.js'; import KnownDevice from '../../../src/settings/knownDevice.js'; import Settings from '../../../src/settings/settings.js'; import DeviceSource from '../../../src/settings/deviceSource.js'; +import ServiceMap from '../../../src/serviceMap.js'; +import { Container, Pimple } from '@timesplinter/pimple'; +import { SlvCtrlPlusDeviceSimulator } from './slvCtrlPlusDeviceSimulator.js'; +import MockSerialPortFactory from './mockSerialPortFactory.js'; process.env.LOG_LEVEL = process.env.LOG_LEVEL ?? 'silent'; @@ -32,35 +36,42 @@ const baseSettingsJson = { }, }; -export const createTestApp = async (): Promise<{ instance: AppInstance, tmpDir: string }> => { +export const createTestApp = async ( + settingsJson: object = baseSettingsJson +): Promise<{ app: AppInstance, container: Container, tmpDir: string, serialPortSimulator: SlvCtrlPlusDeviceSimulator }> => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'slvctrlplus-test-')); const dataPath = tmpDir + path.sep; - fs.writeFileSync(path.join(tmpDir, 'settings.json'), JSON.stringify(baseSettingsJson)); + fs.writeFileSync(path.join(tmpDir, 'settings.json'), JSON.stringify(settingsJson)); - const instance = createApp({ dataPath }); + const options: AppOptions = { dataPath, allowedOrigins: ['*'] }; + const container = createContainer(dataPath); - return { instance, tmpDir }; + const serialPortSimulator = new SlvCtrlPlusDeviceSimulator(); + const mockPortFactory = new MockSerialPortFactory(serialPortSimulator); + container.replace('factory.serialPort', () => mockPortFactory); + + const app = createApp(container, options); + + return { app, container, tmpDir, serialPortSimulator }; }; -export const teardownTestApp = async (instance: AppInstance, tmpDir: string): Promise => { - const scriptRuntime = instance.container.get('automation.scriptRuntime'); +export const teardownTestApp = async (app: AppInstance, container: Container, tmpDir: string): Promise => { + const scriptRuntime = container.get('automation.scriptRuntime'); if (scriptRuntime.isRunning()) { await scriptRuntime.stop(); } - instance.container.get('device.provider.loader').stopProviders(); - - await instance.container.get('device.manager').reset(); + container.get('device.provider.loader').stopProviders(); - await new Promise(resolve => instance.container.get('server.http').close(() => resolve())); + await container.get('device.manager').reset(); fs.rmSync(tmpDir, { recursive: true }); }; -export const resetTestApp = async (instance: AppInstance): Promise => { - const deviceManager = instance.container.get('device.manager'); - const scriptRuntime = instance.container.get('automation.scriptRuntime'); - const settingsManager = instance.container.get('settings.manager'); +export const resetTestApp = async (app: AppInstance, container: Container): Promise => { + const deviceManager = container.get('device.manager'); + const scriptRuntime = container.get('automation.scriptRuntime'); + const settingsManager = container.get('settings.manager'); if (scriptRuntime.isRunning()) { await scriptRuntime.stop(); @@ -102,9 +113,9 @@ export const resetTestApp = async (instance: AppInstance): Promise => { } }; -export const connectDevices = (instance: AppInstance, specs: DeviceSpec[]): Promise => { - const deviceManager = instance.container.get('device.manager'); - const settingsManager = instance.container.get('settings.manager'); +export const connectDevices = (container: Container, specs: DeviceSpec[]): Promise => { + const deviceManager = container.get('device.manager'); + const settingsManager = container.get('settings.manager'); const pendingIds = new Set(specs.map(s => s.id)); diff --git a/tests/integration/serialDevice.spec.ts b/tests/integration/serialDevice.spec.ts deleted file mode 100644 index bedc2d76..00000000 --- a/tests/integration/serialDevice.spec.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { SerialPortMock } from 'serialport'; -import EventEmitter from 'events'; -import { adjectives, animals } from 'unique-names-generator'; -import { PortInfo } from '@serialport/bindings-interface'; -import Settings from '../../src/settings/settings.js'; -import DeviceManager from '../../src/device/deviceManager.js'; -import SlvCtrlPlusSerialDeviceProvider from '../../src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; -import SlvCtrlPlusDeviceFactory from '../../src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.js'; -import SerialDeviceTransportFactory from '../../src/device/transport/serialDeviceTransportFactory.js'; -import UuidFactory from '../../src/factory/uuidFactory.js'; -import DateFactory from '../../src/factory/dateFactory.js'; -import EventEmitterFactory from '../../src/factory/eventEmitterFactory.js'; -import DeviceNameGenerator from '../../src/device/deviceNameGenerator.js'; -import MockSerialPortFactory from './helpers/mockSerialPortFactory.js'; -import { SlvCtrlPlusDeviceSimulator } from './helpers/slvCtrlPlusDeviceSimulator.js'; -import BoolDeviceAttribute from '../../src/device/attribute/boolDeviceAttribute.js'; -import IntDeviceAttribute from '../../src/device/attribute/intDeviceAttribute.js'; -import FloatDeviceAttribute from '../../src/device/attribute/floatDeviceAttribute.js'; -import StrDeviceAttribute from '../../src/device/attribute/strDeviceAttribute.js'; -import IntRangeDeviceAttribute from '../../src/device/attribute/intRangeDeviceAttribute.js'; -import ListDeviceAttribute from '../../src/device/attribute/listDeviceAttribute.js'; -import { DeviceAttributeModifier } from '../../src/device/attribute/deviceAttribute.js'; -import Logger from '../../src/logging/Logger.js'; -import GenericSlvCtrlPlusDevice from '../../src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.js'; -import { Int } from '../../src/util/numbers.js'; - -const TEST_PORT_PATH = '/dev/test-slvctrl-0'; - -const noopLogger: Logger = { - child: () => noopLogger, - trace: () => {}, - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - fatal: () => {}, -}; - -function makePortInfo(): PortInfo { - return { - path: TEST_PORT_PATH, - manufacturer: 'TestMfg', - serialNumber: 'SN-TEST-001', - pnpId: undefined, - locationId: undefined, - vendorId: '1234', // non-Arduino vendorId skips the ReadyParser step - productId: '5678', - }; -} - -describe('SlvCtrl+ serial device', () => { - let provider: SlvCtrlPlusSerialDeviceProvider; - let simulator: SlvCtrlPlusDeviceSimulator; - - beforeEach(() => { - SerialPortMock.binding.createPort(TEST_PORT_PATH, { echo: false, record: false }); - - simulator = new SlvCtrlPlusDeviceSimulator(); - const mockPortFactory = new MockSerialPortFactory(simulator); - - const settings = new Settings(); - const deviceFactory = new SlvCtrlPlusDeviceFactory( - new UuidFactory(), - new DateFactory(), - new EventEmitterFactory(), - settings, - new DeviceNameGenerator({ dictionaries: [adjectives, animals], separator: '-', length: 2 }), - noopLogger, - ); - - provider = new SlvCtrlPlusSerialDeviceProvider( - new DeviceManager(new EventEmitter(), new Map(), noopLogger), - mockPortFactory, - new EventEmitter(), - deviceFactory, - new SerialDeviceTransportFactory(), - noopLogger, - ); - }); - - afterEach(() => { - SerialPortMock.binding.reset(); - }); - - it('connects successfully and returns a device', async () => { - const device = await provider.connectToDevice(makePortInfo()); - - expect(device).toBeInstanceOf(GenericSlvCtrlPlusDevice); - }); - - it('discovers all attribute types with correct types and modifiers', async () => { - const device = (await provider.connectToDevice(makePortInfo())) as GenericSlvCtrlPlusDevice; - - // bool - expect(await device.getAttribute('connected')).toBeInstanceOf(BoolDeviceAttribute); - expect((await device.getAttribute('connected'))?.modifier).toBe(DeviceAttributeModifier.readOnly); - expect(await device.getAttribute('enabled')).toBeInstanceOf(BoolDeviceAttribute); - expect((await device.getAttribute('enabled'))?.modifier).toBe(DeviceAttributeModifier.readWrite); - - // int - expect(await device.getAttribute('counter')).toBeInstanceOf(IntDeviceAttribute); - expect((await device.getAttribute('counter'))?.modifier).toBe(DeviceAttributeModifier.readOnly); - expect(await device.getAttribute('level')).toBeInstanceOf(IntDeviceAttribute); - expect((await device.getAttribute('level'))?.modifier).toBe(DeviceAttributeModifier.readWrite); - - // float - expect(await device.getAttribute('temperature')).toBeInstanceOf(FloatDeviceAttribute); - expect((await device.getAttribute('temperature'))?.modifier).toBe(DeviceAttributeModifier.readOnly); - expect(await device.getAttribute('gain')).toBeInstanceOf(FloatDeviceAttribute); - expect((await device.getAttribute('gain'))?.modifier).toBe(DeviceAttributeModifier.readWrite); - - // string - expect(await device.getAttribute('label')).toBeInstanceOf(StrDeviceAttribute); - expect(await device.getAttribute('mode')).toBeInstanceOf(StrDeviceAttribute); - - // int range with bounds - const intensity = await device.getAttribute('intensity') as IntRangeDeviceAttribute; - expect(intensity).toBeInstanceOf(IntRangeDeviceAttribute); - expect(intensity.min).toBe(0); - expect(intensity.max).toBe(100); - - // string list and int list - expect(await device.getAttribute('preset')).toBeInstanceOf(ListDeviceAttribute); - expect(await device.getAttribute('channel')).toBeInstanceOf(ListDeviceAttribute); - }); - - it('updates all attribute values after refresh', async () => { - const device = (await provider.connectToDevice(makePortInfo())) as GenericSlvCtrlPlusDevice; - - simulator.setValue('level', '7'); - simulator.setValue('enabled', '1'); - simulator.setValue('temperature', '98.6'); - - await device.refresh(); - - expect((await device.getAttribute('level'))?.value).toBe(7); - expect((await device.getAttribute('enabled'))?.value).toBe(true); - expect((await device.getAttribute('temperature'))?.value).toBeCloseTo(98.6); - }); - - it('setAttribute sends the correct serial command for each value type', async () => { - const device = (await provider.connectToDevice(makePortInfo())) as GenericSlvCtrlPlusDevice; - - expect(await device.setAttribute('level', Int.from(9))).toBe(9); - expect(simulator.getValue('level')).toBe('9'); - - expect(await device.setAttribute('enabled', true)).toBe(true); - expect(simulator.getValue('enabled')).toBe('1'); - - expect(await device.setAttribute('mode', 'auto')).toBe('auto'); - expect(simulator.getValue('mode')).toBe('auto'); - - expect(await device.setAttribute('intensity', Int.from(50))).toBe(50); - expect(simulator.getValue('intensity')).toBe('50'); - - expect(await device.setAttribute('preset', 'high')).toBe('high'); - expect(simulator.getValue('preset')).toBe('high'); - }); -}); diff --git a/tests/integration/slvCtrlSerialDevice.spec.ts b/tests/integration/slvCtrlSerialDevice.spec.ts new file mode 100644 index 00000000..8c3c7e8c --- /dev/null +++ b/tests/integration/slvCtrlSerialDevice.spec.ts @@ -0,0 +1,183 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { SerialPortMock } from 'serialport'; +import request from 'supertest'; +import { AppInstance } from '../../src/app.js'; +import { DeviceManagerEvent, SerialDeviceInfo } from '../../src/device/deviceManager.js'; +import Device from '../../src/device/device.js'; +import GenericSlvCtrlPlusDevice from '../../src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.js'; +import SlvCtrlPlusSerialDeviceProvider from '../../src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; +import WebSocketEvent from '../../src/device/webSocketEvent.js'; +import BoolDeviceAttribute from '../../src/device/attribute/boolDeviceAttribute.js'; +import IntDeviceAttribute from '../../src/device/attribute/intDeviceAttribute.js'; +import FloatDeviceAttribute from '../../src/device/attribute/floatDeviceAttribute.js'; +import StrDeviceAttribute from '../../src/device/attribute/strDeviceAttribute.js'; +import IntRangeDeviceAttribute from '../../src/device/attribute/intRangeDeviceAttribute.js'; +import ListDeviceAttribute from '../../src/device/attribute/listDeviceAttribute.js'; +import { DeviceAttributeModifier } from '../../src/device/attribute/deviceAttribute.js'; +import { Int } from '../../src/util/numbers.js'; +import { SlvCtrlPlusDeviceSimulator } from './helpers/slvCtrlPlusDeviceSimulator.js'; +import { createTestApp, teardownTestApp } from './helpers/appHelper.js'; +import ServiceMap from '../../src/serviceMap.js'; +import { Container } from '@timesplinter/pimple'; + +process.env.LOG_LEVEL = process.env.LOG_LEVEL ?? 'silent'; + +const TEST_PORT_PATH = '/dev/test-full-slvctrl-0'; +const SERIAL_SOURCE_ID = 'e6f7a8b9-6789-4321-abcd-ef1234567895'; + +const serialSettings = { + knownDevices: {}, + deviceSources: { + [SERIAL_SOURCE_ID]: { + id: SERIAL_SOURCE_ID, + type: SlvCtrlPlusSerialDeviceProvider.providerName, + config: {}, + }, + }, +}; + +function makePortInfo(): SerialDeviceInfo { + return { + id: 'SN-TEST-FULL-001', + portInfo: { + path: TEST_PORT_PATH, + manufacturer: 'TestMfg', + serialNumber: 'SN-TEST-FULL-001', + pnpId: undefined, + locationId: undefined, + vendorId: '1234', // non-Arduino: skips the ReadyParser step + productId: '5678', + }, + }; +} + +function waitForDeviceConnected(container: Container, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const deviceManager = container.get('device.manager'); + const timeout = setTimeout(() => { + deviceManager.off(DeviceManagerEvent.deviceConnected, listener); + reject(new Error(`Timed out waiting for device to connect (>${timeoutMs}ms)`)); + }, timeoutMs); + + const listener = (device: Device): void => { + clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceConnected, listener); + resolve(device); + }; + + deviceManager.on(DeviceManagerEvent.deviceConnected, listener); + }); +} + +describe('SlvCtrl serial device provider', () => { + let app: AppInstance; + let container: Container; + let tmpDir: string; + let wsEmitSpy: ReturnType; + let serialPortSimulator: SlvCtrlPlusDeviceSimulator; + + beforeAll(async () => { + SerialPortMock.binding.createPort(TEST_PORT_PATH, { echo: false, record: false }); + + ({ app, container, tmpDir, serialPortSimulator } = await createTestApp(serialSettings)); + + wsEmitSpy = vi.spyOn(app.websocket, 'emit'); + + const deviceConnected = waitForDeviceConnected(container); + container.get('device.manager').announceDetectedDevice(makePortInfo()); + await deviceConnected; + }); + + afterAll(async () => { + await teardownTestApp(app, container, tmpDir); + SerialPortMock.binding.reset(); + }); + + it('registers the device in the device manager', () => { + const devices = container.get('device.manager').getConnectedDevices(); + expect(devices).toHaveLength(1); + expect(devices[0]).toBeInstanceOf(GenericSlvCtrlPlusDevice); + }); + + it('exposes the device via GET /devices', async () => { + const res = await request(app.instance).get('/devices'); + expect(res.status).toBe(200); + expect(res.body.count).toBe(1); + expect(res.body.items[0]).toEqual(expect.objectContaining({ + provider: SlvCtrlPlusSerialDeviceProvider.providerName, + type: 'slvCtrlPlus', + })); + }); + + it('emits deviceConnected WebSocket event when the device connects', () => { + expect(wsEmitSpy).toHaveBeenCalledWith( + WebSocketEvent.deviceConnected, + expect.objectContaining({ provider: SlvCtrlPlusSerialDeviceProvider.providerName }), + ); + }); + + describe('device protocol', () => { + it('discovers all attribute types with correct types and modifiers', async () => { + const device = container.get('device.manager').getConnectedDevices()[0] as GenericSlvCtrlPlusDevice; + + expect(await device.getAttribute('connected')).toBeInstanceOf(BoolDeviceAttribute); + expect((await device.getAttribute('connected'))?.modifier).toBe(DeviceAttributeModifier.readOnly); + expect(await device.getAttribute('enabled')).toBeInstanceOf(BoolDeviceAttribute); + expect((await device.getAttribute('enabled'))?.modifier).toBe(DeviceAttributeModifier.readWrite); + + expect(await device.getAttribute('counter')).toBeInstanceOf(IntDeviceAttribute); + expect((await device.getAttribute('counter'))?.modifier).toBe(DeviceAttributeModifier.readOnly); + expect(await device.getAttribute('level')).toBeInstanceOf(IntDeviceAttribute); + expect((await device.getAttribute('level'))?.modifier).toBe(DeviceAttributeModifier.readWrite); + + expect(await device.getAttribute('temperature')).toBeInstanceOf(FloatDeviceAttribute); + expect((await device.getAttribute('temperature'))?.modifier).toBe(DeviceAttributeModifier.readOnly); + expect(await device.getAttribute('gain')).toBeInstanceOf(FloatDeviceAttribute); + expect((await device.getAttribute('gain'))?.modifier).toBe(DeviceAttributeModifier.readWrite); + + expect(await device.getAttribute('label')).toBeInstanceOf(StrDeviceAttribute); + expect(await device.getAttribute('mode')).toBeInstanceOf(StrDeviceAttribute); + + const intensity = await device.getAttribute('intensity') as IntRangeDeviceAttribute; + expect(intensity).toBeInstanceOf(IntRangeDeviceAttribute); + expect(intensity.min).toBe(0); + expect(intensity.max).toBe(100); + + expect(await device.getAttribute('preset')).toBeInstanceOf(ListDeviceAttribute); + expect(await device.getAttribute('channel')).toBeInstanceOf(ListDeviceAttribute); + }); + + it('updates all attribute values after refresh', async () => { + const device = container.get('device.manager').getConnectedDevices()[0] as GenericSlvCtrlPlusDevice; + + serialPortSimulator.setValue('level', '7'); + serialPortSimulator.setValue('enabled', '1'); + serialPortSimulator.setValue('temperature', '98.6'); + + await device.refresh(); + + expect((await device.getAttribute('level'))?.value).toBe(7); + expect((await device.getAttribute('enabled'))?.value).toBe(true); + expect((await device.getAttribute('temperature'))?.value).toBeCloseTo(98.6); + }); + + it('setAttribute sends the correct serial command for each value type', async () => { + const device = container.get('device.manager').getConnectedDevices()[0] as GenericSlvCtrlPlusDevice; + + expect(await device.setAttribute('level', Int.from(9))).toBe(9); + expect(serialPortSimulator.getValue('level')).toBe('9'); + + expect(await device.setAttribute('enabled', true)).toBe(true); + expect(serialPortSimulator.getValue('enabled')).toBe('1'); + + expect(await device.setAttribute('mode', 'auto')).toBe('auto'); + expect(serialPortSimulator.getValue('mode')).toBe('auto'); + + expect(await device.setAttribute('intensity', Int.from(50))).toBe(50); + expect(serialPortSimulator.getValue('intensity')).toBe('50'); + + expect(await device.setAttribute('preset', 'high')).toBe('high'); + expect(serialPortSimulator.getValue('preset')).toBe('high'); + }); + }); +}); From 78ab175311573e2bd80de0c814145334af350e46 Mon Sep 17 00:00:00 2001 From: HRS Date: Fri, 19 Jun 2026 08:17:34 +0200 Subject: [PATCH 23/23] Improve intgeration tests --- package-lock.json | 40 ++ package.json | 5 +- tests/integration/helpers/appHelper.ts | 9 +- .../helpers/mockSerialPortFactory.ts | 38 +- .../helpers/slvCtrlPlusDeviceSimulator.ts | 85 +++- tests/integration/slvCtrlSerialDevice.spec.ts | 480 ++++++++++++++---- 6 files changed, 541 insertions(+), 116 deletions(-) diff --git a/package-lock.json b/package-lock.json index ec67bbb1..8260001d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "jiti": "^2.6.1", "nodemon": "^3.0.2", "pino-pretty": "^10.3.0", + "socket.io-client": "^4.8.3", "supertest": "^7.2.2", "typescript": "^5.9.3", "typescript-eslint": "^8.52.0", @@ -3962,6 +3963,20 @@ "node": ">=10.2.0" } }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, "node_modules/engine.io-parser": { "version": "5.2.3", "license": "MIT", @@ -7602,6 +7617,22 @@ } } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/socket.io-parser": { "version": "4.2.6", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", @@ -8721,6 +8752,15 @@ } } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "dev": true, diff --git a/package.json b/package.json index 3d90fc18..5985b8ae 100644 --- a/package.json +++ b/package.json @@ -36,10 +36,12 @@ "@babel/plugin-proposal-decorators": "^7.22.7", "@babel/preset-env": "^7.14.4", "@babel/preset-typescript": "^7.22.5", + "@serialport/binding-mock": "^10.2.2", "@types/cors": "^2.8.10", "@types/express": "^5.0.6", "@types/node": "^24.1.0", "@types/supertest": "^7.2.0", + "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.0.16", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", @@ -49,11 +51,10 @@ "jiti": "^2.6.1", "nodemon": "^3.0.2", "pino-pretty": "^10.3.0", + "socket.io-client": "^4.8.3", "supertest": "^7.2.2", "typescript": "^5.9.3", "typescript-eslint": "^8.52.0", - "@serialport/binding-mock": "^10.2.2", - "@types/ws": "^8.18.1", "vitest": "^4.0.16", "vitest-mock-extended": "^3.1.0", "ws": "^8.21.0" diff --git a/tests/integration/helpers/appHelper.ts b/tests/integration/helpers/appHelper.ts index 9cc06df9..39c21e8a 100644 --- a/tests/integration/helpers/appHelper.ts +++ b/tests/integration/helpers/appHelper.ts @@ -38,7 +38,7 @@ const baseSettingsJson = { export const createTestApp = async ( settingsJson: object = baseSettingsJson -): Promise<{ app: AppInstance, container: Container, tmpDir: string, serialPortSimulator: SlvCtrlPlusDeviceSimulator }> => { +): Promise<{ app: AppInstance, container: Container, tmpDir: string, mockSerialPortFactory: MockSerialPortFactory }> => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'slvctrlplus-test-')); const dataPath = tmpDir + path.sep; fs.writeFileSync(path.join(tmpDir, 'settings.json'), JSON.stringify(settingsJson)); @@ -46,13 +46,12 @@ export const createTestApp = async ( const options: AppOptions = { dataPath, allowedOrigins: ['*'] }; const container = createContainer(dataPath); - const serialPortSimulator = new SlvCtrlPlusDeviceSimulator(); - const mockPortFactory = new MockSerialPortFactory(serialPortSimulator); - container.replace('factory.serialPort', () => mockPortFactory); + const mockSerialPortFactory = new MockSerialPortFactory(); + container.replace('factory.serialPort', () => mockSerialPortFactory); const app = createApp(container, options); - return { app, container, tmpDir, serialPortSimulator }; + return { app, container, tmpDir, mockSerialPortFactory }; }; export const teardownTestApp = async (app: AppInstance, container: Container, tmpDir: string): Promise => { diff --git a/tests/integration/helpers/mockSerialPortFactory.ts b/tests/integration/helpers/mockSerialPortFactory.ts index 877cf8eb..006a6097 100644 --- a/tests/integration/helpers/mockSerialPortFactory.ts +++ b/tests/integration/helpers/mockSerialPortFactory.ts @@ -1,6 +1,6 @@ -import { SerialPortMock } from 'serialport'; +import { SerialPort, SerialPortMock } from 'serialport'; import { SerialPortStream } from '@serialport/stream'; -import { BindingInterface } from '@serialport/bindings-interface'; +import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; import { SerialPortOpenOptions } from 'serialport'; import { AutoDetectTypes } from '@serialport/bindings-cpp'; import SerialPortFactory from '../../../src/factory/serialPortFactory.js'; @@ -8,15 +8,29 @@ import { SlvCtrlPlusDeviceSimulator } from './slvCtrlPlusDeviceSimulator.js'; /** * A SerialPortFactory that creates SerialPortMock instances backed by - * MockBinding instead of real hardware. The provided simulator is wired - * up to each created port as soon as it opens. + * MockBinding instead of real hardware. + * + * Supports routing different port paths to different simulators via a Map. + * A single-simulator shorthand constructor is provided for backwards compatibility. */ export default class MockSerialPortFactory extends SerialPortFactory { - private readonly simulator: SlvCtrlPlusDeviceSimulator; + private readonly simulators: Map = new Map(); - public constructor(simulator: SlvCtrlPlusDeviceSimulator) { + public constructor(simulator?: SlvCtrlPlusDeviceSimulator) { super(); - this.simulator = simulator; + + SerialPort.list = () => SerialPortMock.list(); + } + + public attachDevice(path: string, simulator: SlvCtrlPlusDeviceSimulator): void { + SerialPortMock.binding.createPort(path, { echo: false, record: false, vendorId: '1234', productId: '5678', manufacturer: 'NotArduino' }); // Arduino Uno + + this.simulators.set(path, simulator); + } + + public reset(): void { + this.simulators.clear(); + SerialPortMock.binding.reset(); } public override create(options: SerialPortOpenOptions): SerialPortStream { @@ -26,10 +40,16 @@ export default class MockSerialPortFactory extends SerialPortFactory { autoOpen: false, }); + const simulator = this.simulators.get(options.path); + + if (!simulator) { + throw new Error(`No simulator attached for path ${options.path}`); + } + // Attach the simulator once the port binding is ready (after open). mockPort.once('open', () => { - if (mockPort.port) { - this.simulator.attachToPort(mockPort.port); + if (mockPort.port && simulator) { + simulator.attachToPort(mockPort.port); } }); diff --git a/tests/integration/helpers/slvCtrlPlusDeviceSimulator.ts b/tests/integration/helpers/slvCtrlPlusDeviceSimulator.ts index c50f30f1..8dfee50a 100644 --- a/tests/integration/helpers/slvCtrlPlusDeviceSimulator.ts +++ b/tests/integration/helpers/slvCtrlPlusDeviceSimulator.ts @@ -6,14 +6,18 @@ export interface SlvCtrlPlusDeviceSimulatorConfig { deviceType?: string; fwVersion?: number; protocolVersion?: number; + protocol?: 'v1' | 'legacy'; } /** - * Simulates a SlvCtrl+ V1 protocol device backed by a MockPortBinding. + * Simulates a SlvCtrl+ device backed by a MockPortBinding. * - * It intercepts writes from the application, parses the command, and emits - * the appropriate protocol response via emitData(). The test device exposes - * all supported attribute types so every parsing path can be exercised. + * Supports both the V1 protocol and the legacy ( = { connected: '1', enabled: '0', @@ -64,11 +85,14 @@ export class SlvCtrlPlusDeviceSimulator { public constructor(config: SlvCtrlPlusDeviceSimulatorConfig = {}) { this.deviceType = config.deviceType ?? 'testDevice'; this.fwVersion = config.fwVersion ?? 1; - this.protocolVersion = config.protocolVersion ?? 1; + this.protocol = config.protocol ?? 'v1'; + // Legacy protocol version defaults to 0 to signal pre-V1; V1 defaults to 1 + this.protocolVersion = config.protocolVersion ?? (this.protocol === 'legacy' ? 0 : 1); } /** Wire the simulator to a MockPortBinding once the port is open. */ public attachToPort(bindingPort: MockPortBinding): void { + this.port = bindingPort; const originalWrite = bindingPort.write.bind(bindingPort); bindingPort.write = async (buffer: Buffer): Promise => { @@ -87,6 +111,14 @@ export class SlvCtrlPlusDeviceSimulator { }; } + /** Simulate the hardware being unplugged by closing the port binding. */ + public disconnect(): Promise { + if (this.port === null) { + return Promise.resolve(); + } + return this.port.close(); + } + public getValue(name: string): string | undefined { return this.values[name]; } @@ -95,28 +127,42 @@ export class SlvCtrlPlusDeviceSimulator { this.values[name] = value; } + private get attributeDefs(): string[] { + return this.protocol === 'legacy' ? this.legacyAttributeDefs : this.v1AttributeDefs; + } + private respond(command: string): string | null { if (command === 'clear') { - return 'clear;;status:ok'; + return this.protocol === 'legacy' ? 'clear;;ok' : 'clear;;status:ok'; } if (command === 'introduce') { + if (this.protocol === 'legacy') { + // Legacy format: positional, no status suffix — detected by the device factory + // via the regex /^introduce;([^,;]+),(\d+),(\d+)$/ + return `introduce;${this.deviceType},${this.fwVersion},${this.protocolVersion}`; + } return `introduce;type:${this.deviceType},fw:${this.fwVersion},protocol:${this.protocolVersion};status:ok`; } if (command === 'attributes') { const attrStr = this.attributeDefs.join(','); - return `attributes;${attrStr};status:ok`; + return this.protocol === 'legacy' + ? `attributes;${attrStr};ok` + : `attributes;${attrStr};status:ok`; } if (command === 'status') { const statusStr = Object.entries(this.values) .map(([k, v]) => `${k}:${v}`) .join(','); - return `status;${statusStr};status:ok`; + return this.protocol === 'legacy' + ? `status;${statusStr};ok` + : `status;${statusStr};status:ok`; } - if (command.startsWith('set ')) { + // V1 set command: "set level 5" + if (this.protocol === 'v1' && command.startsWith('set ')) { const parts = command.split(' '); const attrName = parts[1]; const value = parts.slice(2).join(' '); @@ -126,6 +172,19 @@ export class SlvCtrlPlusDeviceSimulator { return `${command};value:${value};status:ok`; } + // Legacy set command: "set-level 5" + if (this.protocol === 'legacy' && command.startsWith('set-')) { + const withoutPrefix = command.slice(4); // strip "set-" + const spaceIdx = withoutPrefix.indexOf(' '); + const attrName = spaceIdx >= 0 ? withoutPrefix.slice(0, spaceIdx) : withoutPrefix; + const value = spaceIdx >= 0 ? withoutPrefix.slice(spaceIdx + 1) : ''; + if (attrName) { + this.values[attrName] = value; + } + // Legacy result suffix is plain "ok", not "status:ok" + return `${command};${value};ok`; + } + return null; } } diff --git a/tests/integration/slvCtrlSerialDevice.spec.ts b/tests/integration/slvCtrlSerialDevice.spec.ts index 8c3c7e8c..82ee85d9 100644 --- a/tests/integration/slvCtrlSerialDevice.spec.ts +++ b/tests/integration/slvCtrlSerialDevice.spec.ts @@ -1,28 +1,23 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { SerialPortMock } from 'serialport'; import request from 'supertest'; +import { io as ioClient, Socket as ClientSocket } from 'socket.io-client'; import { AppInstance } from '../../src/app.js'; -import { DeviceManagerEvent, SerialDeviceInfo } from '../../src/device/deviceManager.js'; +import { DeviceManagerEvent } from '../../src/device/deviceManager.js'; import Device from '../../src/device/device.js'; import GenericSlvCtrlPlusDevice from '../../src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.js'; import SlvCtrlPlusSerialDeviceProvider from '../../src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; import WebSocketEvent from '../../src/device/webSocketEvent.js'; -import BoolDeviceAttribute from '../../src/device/attribute/boolDeviceAttribute.js'; -import IntDeviceAttribute from '../../src/device/attribute/intDeviceAttribute.js'; -import FloatDeviceAttribute from '../../src/device/attribute/floatDeviceAttribute.js'; -import StrDeviceAttribute from '../../src/device/attribute/strDeviceAttribute.js'; -import IntRangeDeviceAttribute from '../../src/device/attribute/intRangeDeviceAttribute.js'; -import ListDeviceAttribute from '../../src/device/attribute/listDeviceAttribute.js'; -import { DeviceAttributeModifier } from '../../src/device/attribute/deviceAttribute.js'; -import { Int } from '../../src/util/numbers.js'; import { SlvCtrlPlusDeviceSimulator } from './helpers/slvCtrlPlusDeviceSimulator.js'; import { createTestApp, teardownTestApp } from './helpers/appHelper.js'; import ServiceMap from '../../src/serviceMap.js'; import { Container } from '@timesplinter/pimple'; +import MockSerialPortFactory from './helpers/mockSerialPortFactory.js'; process.env.LOG_LEVEL = process.env.LOG_LEVEL ?? 'silent'; -const TEST_PORT_PATH = '/dev/test-full-slvctrl-0'; +const V1_PORT_PATH = '/dev/test-slvctrl-v1-0'; +const LEGACY_PORT_PATH = '/dev/test-slvctrl-legacy-0'; + const SERIAL_SOURCE_ID = 'e6f7a8b9-6789-4321-abcd-ef1234567895'; const serialSettings = { @@ -36,123 +31,195 @@ const serialSettings = { }, }; -function makePortInfo(): SerialDeviceInfo { - return { - id: 'SN-TEST-FULL-001', - portInfo: { - path: TEST_PORT_PATH, - manufacturer: 'TestMfg', - serialNumber: 'SN-TEST-FULL-001', - pnpId: undefined, - locationId: undefined, - vendorId: '1234', // non-Arduino: skips the ReadyParser step - productId: '5678', - }, - }; -} - -function waitForDeviceConnected(container: Container, timeoutMs = 5000): Promise { +function waitForNDevicesConnected(container: Container, deviceCount: number, timeoutMs = 5000): Promise { return new Promise((resolve, reject) => { const deviceManager = container.get('device.manager'); + const connected: Device[] = []; + const timeout = setTimeout(() => { deviceManager.off(DeviceManagerEvent.deviceConnected, listener); - reject(new Error(`Timed out waiting for device to connect (>${timeoutMs}ms)`)); + reject(new Error(`Timed out waiting for ${deviceCount} device(s) to connect (>${timeoutMs}ms), got ${connected.length}`)); }, timeoutMs); const listener = (device: Device): void => { - clearTimeout(timeout); - deviceManager.off(DeviceManagerEvent.deviceConnected, listener); - resolve(device); + connected.push(device); + if (connected.length >= deviceCount) { + clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceConnected, listener); + resolve(connected); + } }; deviceManager.on(DeviceManagerEvent.deviceConnected, listener); }); } +function waitForDeviceDisconnected(container: Container, deviceId: string, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const deviceManager = container.get('device.manager'); + const timeout = setTimeout(() => { + deviceManager.off(DeviceManagerEvent.deviceDisconnected, listener); + reject(new Error(`Timed out waiting for device ${deviceId} to disconnect (>${timeoutMs}ms)`)); + }, timeoutMs); + + const listener = (device: Device): void => { + if (device.getDeviceId === deviceId) { + clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceDisconnected, listener); + resolve(); + } + }; + + deviceManager.on(DeviceManagerEvent.deviceDisconnected, listener); + }); +} + describe('SlvCtrl serial device provider', () => { let app: AppInstance; let container: Container; let tmpDir: string; let wsEmitSpy: ReturnType; - let serialPortSimulator: SlvCtrlPlusDeviceSimulator; + let mockSerialPortFactory: MockSerialPortFactory; + let v1Simulator: SlvCtrlPlusDeviceSimulator; + let legacySimulator: SlvCtrlPlusDeviceSimulator; beforeAll(async () => { - SerialPortMock.binding.createPort(TEST_PORT_PATH, { echo: false, record: false }); + v1Simulator = new SlvCtrlPlusDeviceSimulator({ protocol: 'v1', deviceType: 'testDeviceV1' }); + legacySimulator = new SlvCtrlPlusDeviceSimulator({ protocol: 'legacy', deviceType: 'testDeviceLegacy' }); - ({ app, container, tmpDir, serialPortSimulator } = await createTestApp(serialSettings)); + ({ app, container, tmpDir, mockSerialPortFactory } = await createTestApp(serialSettings)); + mockSerialPortFactory.attachDevice(V1_PORT_PATH, v1Simulator); + mockSerialPortFactory.attachDevice(LEGACY_PORT_PATH, legacySimulator); + wsEmitSpy = vi.spyOn(app.websocket, 'emit'); - const deviceConnected = waitForDeviceConnected(container); - container.get('device.manager').announceDetectedDevice(makePortInfo()); - await deviceConnected; + const bothConnected = waitForNDevicesConnected(container, 2); + await bothConnected; }); afterAll(async () => { await teardownTestApp(app, container, tmpDir); - SerialPortMock.binding.reset(); + mockSerialPortFactory.reset(); }); - it('registers the device in the device manager', () => { + it('registers both devices in the device manager', () => { const devices = container.get('device.manager').getConnectedDevices(); - expect(devices).toHaveLength(1); - expect(devices[0]).toBeInstanceOf(GenericSlvCtrlPlusDevice); + expect(devices).toHaveLength(2); + expect(devices.every(d => d instanceof GenericSlvCtrlPlusDevice)).toBe(true); }); - it('exposes the device via GET /devices', async () => { + it('exposes both devices via GET /devices', async () => { const res = await request(app.instance).get('/devices'); expect(res.status).toBe(200); - expect(res.body.count).toBe(1); - expect(res.body.items[0]).toEqual(expect.objectContaining({ + expect(res.body.count).toBe(2); + expect(res.body.items.every((item: { provider: string }) => + item.provider === SlvCtrlPlusSerialDeviceProvider.providerName + )).toBe(true); + }); + + it('emits deviceConnected WebSocket event for each device', () => { + const calls = wsEmitSpy.mock.calls.filter(([event]: [string, ...unknown[]]) => event === WebSocketEvent.deviceConnected); + expect(calls).toHaveLength(2); + }); + + it('deviceConnected WebSocket event includes serialized device with all attributes', () => { + const calls = wsEmitSpy.mock.calls.filter(([event]: [string, ...unknown[]]) => event === WebSocketEvent.deviceConnected); + expect(calls).toHaveLength(2); + + // Find the V1 device event + const v1DeviceEvent = calls.find(([, payload]: [string, any]) => + payload.deviceName?.includes('testDeviceV1') || payload.deviceModel === 'testDeviceV1' + ); + expect(v1DeviceEvent).toBeDefined(); + + const [, v1Payload] = v1DeviceEvent as [string, any]; + expect(v1Payload).toEqual(expect.objectContaining({ + deviceModel: 'testDeviceV1', provider: SlvCtrlPlusSerialDeviceProvider.providerName, - type: 'slvCtrlPlus', + attributes: expect.objectContaining({ + connected: expect.objectContaining({ type: 'bool', modifier: 'ro' }), + enabled: expect.objectContaining({ type: 'bool', modifier: 'rw' }), + counter: expect.objectContaining({ type: 'int', modifier: 'ro' }), + level: expect.objectContaining({ type: 'int', modifier: 'rw' }), + temperature: expect.objectContaining({ type: 'float', modifier: 'ro' }), + gain: expect.objectContaining({ type: 'float', modifier: 'rw' }), + label: expect.objectContaining({ type: 'str', modifier: 'ro' }), + mode: expect.objectContaining({ type: 'str', modifier: 'rw' }), + intensity: expect.objectContaining({ type: 'range', modifier: 'rw', min: 0, max: 100 }), + preset: expect.objectContaining({ type: 'list', modifier: 'rw' }), + channel: expect.objectContaining({ type: 'list', modifier: 'rw' }), + }) })); - }); - it('emits deviceConnected WebSocket event when the device connects', () => { - expect(wsEmitSpy).toHaveBeenCalledWith( - WebSocketEvent.deviceConnected, - expect.objectContaining({ provider: SlvCtrlPlusSerialDeviceProvider.providerName }), + // Find the legacy device event + const legacyDeviceEvent = calls.find(([, payload]: [string, any]) => + payload.deviceName?.includes('testDeviceLegacy') || payload.deviceModel === 'testDeviceLegacy' ); + expect(legacyDeviceEvent).toBeDefined(); + + const [, legacyPayload] = legacyDeviceEvent as [string, any]; + expect(legacyPayload).toEqual(expect.objectContaining({ + deviceModel: 'testDeviceLegacy', + provider: SlvCtrlPlusSerialDeviceProvider.providerName, + attributes: expect.objectContaining({ + connected: expect.objectContaining({ type: 'bool', modifier: 'ro' }), + enabled: expect.objectContaining({ type: 'bool', modifier: 'rw' }), + counter: expect.objectContaining({ type: 'int', modifier: 'ro' }), + level: expect.objectContaining({ type: 'int', modifier: 'rw' }), + temperature: expect.objectContaining({ type: 'float', modifier: 'ro' }), + gain: expect.objectContaining({ type: 'float', modifier: 'rw' }), + label: expect.objectContaining({ type: 'str', modifier: 'ro' }), + mode: expect.objectContaining({ type: 'str', modifier: 'rw' }), + intensity: expect.objectContaining({ type: 'range', modifier: 'rw', min: 0, max: 100 }), + preset: expect.objectContaining({ type: 'list', modifier: 'rw' }), + channel: expect.objectContaining({ type: 'list', modifier: 'rw' }), + }) + })); }); - describe('device protocol', () => { + function getDevice(model: string): GenericSlvCtrlPlusDevice { + const devices = container.get('device.manager').getConnectedDevices() as GenericSlvCtrlPlusDevice[]; + const device = devices.find(d => d.getDeviceModel === model); + if (undefined === device) { + throw new Error(`Device with model '${model}' not found`); + } + return device; + } + + describe('V1 protocol device', () => { it('discovers all attribute types with correct types and modifiers', async () => { - const device = container.get('device.manager').getConnectedDevices()[0] as GenericSlvCtrlPlusDevice; + const device = getDevice('testDeviceV1'); + const res = await request(app.instance).get(`/device/${device.getDeviceId}`); + + expect(res.status).toBe(200); + const attributes = res.body.attributes; - expect(await device.getAttribute('connected')).toBeInstanceOf(BoolDeviceAttribute); - expect((await device.getAttribute('connected'))?.modifier).toBe(DeviceAttributeModifier.readOnly); - expect(await device.getAttribute('enabled')).toBeInstanceOf(BoolDeviceAttribute); - expect((await device.getAttribute('enabled'))?.modifier).toBe(DeviceAttributeModifier.readWrite); + expect(attributes.connected).toEqual(expect.objectContaining({ type: 'bool', modifier: 'ro' })); + expect(attributes.enabled).toEqual(expect.objectContaining({ type: 'bool', modifier: 'rw' })); - expect(await device.getAttribute('counter')).toBeInstanceOf(IntDeviceAttribute); - expect((await device.getAttribute('counter'))?.modifier).toBe(DeviceAttributeModifier.readOnly); - expect(await device.getAttribute('level')).toBeInstanceOf(IntDeviceAttribute); - expect((await device.getAttribute('level'))?.modifier).toBe(DeviceAttributeModifier.readWrite); + expect(attributes.counter).toEqual(expect.objectContaining({ type: 'int', modifier: 'ro' })); + expect(attributes.level).toEqual(expect.objectContaining({ type: 'int', modifier: 'rw' })); - expect(await device.getAttribute('temperature')).toBeInstanceOf(FloatDeviceAttribute); - expect((await device.getAttribute('temperature'))?.modifier).toBe(DeviceAttributeModifier.readOnly); - expect(await device.getAttribute('gain')).toBeInstanceOf(FloatDeviceAttribute); - expect((await device.getAttribute('gain'))?.modifier).toBe(DeviceAttributeModifier.readWrite); + expect(attributes.temperature).toEqual(expect.objectContaining({ type: 'float', modifier: 'ro' })); + expect(attributes.gain).toEqual(expect.objectContaining({ type: 'float', modifier: 'rw' })); - expect(await device.getAttribute('label')).toBeInstanceOf(StrDeviceAttribute); - expect(await device.getAttribute('mode')).toBeInstanceOf(StrDeviceAttribute); + expect(attributes.label).toEqual(expect.objectContaining({ type: 'str', modifier: 'ro' })); + expect(attributes.mode).toEqual(expect.objectContaining({ type: 'str', modifier: 'rw' })); - const intensity = await device.getAttribute('intensity') as IntRangeDeviceAttribute; - expect(intensity).toBeInstanceOf(IntRangeDeviceAttribute); - expect(intensity.min).toBe(0); - expect(intensity.max).toBe(100); + expect(attributes.intensity).toEqual(expect.objectContaining({ type: 'range', modifier: 'rw', min: 0, max: 100 })); - expect(await device.getAttribute('preset')).toBeInstanceOf(ListDeviceAttribute); - expect(await device.getAttribute('channel')).toBeInstanceOf(ListDeviceAttribute); + // V1: preset is str list, channel is int list + expect(attributes.preset).toEqual(expect.objectContaining({ type: 'list', modifier: 'rw' })); + expect(attributes.channel).toEqual(expect.objectContaining({ type: 'list', modifier: 'rw' })); }); it('updates all attribute values after refresh', async () => { - const device = container.get('device.manager').getConnectedDevices()[0] as GenericSlvCtrlPlusDevice; + const device = getDevice('testDeviceV1'); - serialPortSimulator.setValue('level', '7'); - serialPortSimulator.setValue('enabled', '1'); - serialPortSimulator.setValue('temperature', '98.6'); + v1Simulator.setValue('level', '7'); + v1Simulator.setValue('enabled', '1'); + v1Simulator.setValue('temperature', '98.6'); await device.refresh(); @@ -162,22 +229,261 @@ describe('SlvCtrl serial device provider', () => { }); it('setAttribute sends the correct serial command for each value type', async () => { - const device = container.get('device.manager').getConnectedDevices()[0] as GenericSlvCtrlPlusDevice; + const device = getDevice('testDeviceV1'); - expect(await device.setAttribute('level', Int.from(9))).toBe(9); - expect(serialPortSimulator.getValue('level')).toBe('9'); + await request(app.instance).patch(`/device/${device.getDeviceId}`).send({ level: 9 }).expect(202); + expect(v1Simulator.getValue('level')).toBe('9'); + expect((await device.getAttribute('level'))?.value).toBe(9); + + await request(app.instance).patch(`/device/${device.getDeviceId}`).send({ enabled: true }).expect(202); + expect(v1Simulator.getValue('enabled')).toBe('1'); + expect((await device.getAttribute('enabled'))?.value).toBe(true); - expect(await device.setAttribute('enabled', true)).toBe(true); - expect(serialPortSimulator.getValue('enabled')).toBe('1'); + await request(app.instance).patch(`/device/${device.getDeviceId}`).send({ mode: 'auto' }).expect(202); + expect(v1Simulator.getValue('mode')).toBe('auto'); + expect((await device.getAttribute('mode'))?.value).toBe('auto'); - expect(await device.setAttribute('mode', 'auto')).toBe('auto'); - expect(serialPortSimulator.getValue('mode')).toBe('auto'); + await request(app.instance).patch(`/device/${device.getDeviceId}`).send({ intensity: 50 }).expect(202); + expect(v1Simulator.getValue('intensity')).toBe('50'); + expect((await device.getAttribute('intensity'))?.value).toBe(50); + + await request(app.instance).patch(`/device/${device.getDeviceId}`).send({ preset: 'high' }).expect(202); + expect(v1Simulator.getValue('preset')).toBe('high'); + expect((await device.getAttribute('preset'))?.value).toBe('high'); + }); + + it('updates device attributes via WebSocket events', async () => { + const device = getDevice('testDeviceV1'); + const testPort = 13371; + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Server failed to start')), 2000); + app.serve(testPort); + setTimeout(() => { + clearTimeout(timeout); + resolve(); + }, 100); + }); + + const client = ioClient(`http://localhost:${testPort}`); + + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Failed to connect WebSocket client')), 2000); + client.on('connect', () => { + clearTimeout(timeout); + resolve(); + }); + client.on('connect_error', reject); + }); + + client.emit(WebSocketEvent.deviceUpdateReceived, { + deviceId: device.getDeviceId, + data: { level: 12 } + }); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(v1Simulator.getValue('level')).toBe('12'); + expect((await device.getAttribute('level'))?.value).toBe(12); + + client.emit(WebSocketEvent.deviceUpdateReceived, { + deviceId: device.getDeviceId, + data: { enabled: false } + }); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(v1Simulator.getValue('enabled')).toBe('0'); + expect((await device.getAttribute('enabled'))?.value).toBe(false); + + client.emit(WebSocketEvent.deviceUpdateReceived, { + deviceId: device.getDeviceId, + data: { intensity: 75 } + }); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(v1Simulator.getValue('intensity')).toBe('75'); + expect((await device.getAttribute('intensity'))?.value).toBe(75); + } finally { + client.disconnect(); + } + }); + }); + + describe('legacy protocol device', () => { + it('discovers all attribute types with correct types and modifiers', async () => { + const device = getDevice('testDeviceLegacy'); + const res = await request(app.instance).get(`/device/${device.getDeviceId}`); + + expect(res.status).toBe(200); + const attributes = res.body.attributes; + + expect(attributes.connected).toEqual(expect.objectContaining({ type: 'bool', modifier: 'ro' })); + expect(attributes.enabled).toEqual(expect.objectContaining({ type: 'bool', modifier: 'rw' })); + + expect(attributes.counter).toEqual(expect.objectContaining({ type: 'int', modifier: 'ro' })); + expect(attributes.level).toEqual(expect.objectContaining({ type: 'int', modifier: 'rw' })); + + expect(attributes.temperature).toEqual(expect.objectContaining({ type: 'float', modifier: 'ro' })); + expect(attributes.gain).toEqual(expect.objectContaining({ type: 'float', modifier: 'rw' })); + + expect(attributes.label).toEqual(expect.objectContaining({ type: 'str', modifier: 'ro' })); + expect(attributes.mode).toEqual(expect.objectContaining({ type: 'str', modifier: 'rw' })); + + // Legacy: range is "0-100" syntax + expect(attributes.intensity).toEqual(expect.objectContaining({ type: 'range', modifier: 'rw', min: 0, max: 100 })); + + // Legacy: lists are plain string values + expect(attributes.preset).toEqual(expect.objectContaining({ type: 'list', modifier: 'rw' })); + expect(attributes.channel).toEqual(expect.objectContaining({ type: 'list', modifier: 'rw' })); + }); + + it('updates all attribute values after refresh', async () => { + const device = getDevice('testDeviceLegacy'); + + legacySimulator.setValue('level', '3'); + legacySimulator.setValue('enabled', '1'); + legacySimulator.setValue('temperature', '37.2'); + + await device.refresh(); + + expect((await device.getAttribute('level'))?.value).toBe(3); + expect((await device.getAttribute('enabled'))?.value).toBe(true); + expect((await device.getAttribute('temperature'))?.value).toBeCloseTo(37.2); + }); + + it('setAttribute sends the correct serial command for each value type', async () => { + const device = getDevice('testDeviceLegacy'); + + await request(app.instance).patch(`/device/${device.getDeviceId}`).send({ level: 4 }).expect(202); + expect(legacySimulator.getValue('level')).toBe('4'); + expect((await device.getAttribute('level'))?.value).toBe(4); + + await request(app.instance).patch(`/device/${device.getDeviceId}`).send({ enabled: true }).expect(202); + expect(legacySimulator.getValue('enabled')).toBe('1'); + expect((await device.getAttribute('enabled'))?.value).toBe(true); + + await request(app.instance).patch(`/device/${device.getDeviceId}`).send({ mode: 'auto' }).expect(202); + expect(legacySimulator.getValue('mode')).toBe('auto'); + expect((await device.getAttribute('mode'))?.value).toBe('auto'); + + await request(app.instance).patch(`/device/${device.getDeviceId}`).send({ intensity: 25 }).expect(202); + expect(legacySimulator.getValue('intensity')).toBe('25'); + expect((await device.getAttribute('intensity'))?.value).toBe(25); + + await request(app.instance).patch(`/device/${device.getDeviceId}`).send({ preset: 'medium' }).expect(202); + expect(legacySimulator.getValue('preset')).toBe('medium'); + expect((await device.getAttribute('preset'))?.value).toBe('medium'); + }); + + it('updates device attributes via WebSocket events', async () => { + const device = getDevice('testDeviceLegacy'); + const testPort = 13372; + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Server failed to start')), 2000); + app.serve(testPort); + setTimeout(() => { + clearTimeout(timeout); + resolve(); + }, 100); + }); + + const client = ioClient(`http://localhost:${testPort}`); + + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Failed to connect WebSocket client')), 2000); + client.on('connect', () => { + clearTimeout(timeout); + resolve(); + }); + client.on('connect_error', reject); + }); + + client.emit(WebSocketEvent.deviceUpdateReceived, { + deviceId: device.getDeviceId, + data: { level: 8 } + }); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(legacySimulator.getValue('level')).toBe('8'); + expect((await device.getAttribute('level'))?.value).toBe(8); + + client.emit(WebSocketEvent.deviceUpdateReceived, { + deviceId: device.getDeviceId, + data: { enabled: false } + }); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(legacySimulator.getValue('enabled')).toBe('0'); + expect((await device.getAttribute('enabled'))?.value).toBe(false); + + client.emit(WebSocketEvent.deviceUpdateReceived, { + deviceId: device.getDeviceId, + data: { preset: 'high' } + }); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(legacySimulator.getValue('preset')).toBe('high'); + expect((await device.getAttribute('preset'))?.value).toBe('high'); + } finally { + client.disconnect(); + } + }); + }); + + describe('Device disconnection', () => { + let disconnectedV1DeviceId: string; + let disconnectedLegacyDeviceId: string; + + it('emits deviceDisconnected WebSocket event when device disconnects', async () => { + const device = getDevice('testDeviceV1'); + disconnectedV1DeviceId = device.getDeviceId; + + wsEmitSpy.mockClear(); + + // Disconnect the device - in production this would happen when hardware is unplugged + // and SerialPortObserver detects the missing port. MockBinding limitations prevent + // testing the full port-removal flow, but device.close() triggers the same events. + const disconnectPromise = waitForDeviceDisconnected(container, disconnectedV1DeviceId); + await device.close(); + await disconnectPromise; + + const calls = wsEmitSpy.mock.calls.filter(([event]: [string, ...unknown[]]) => event === WebSocketEvent.deviceDisconnected); + expect(calls).toHaveLength(1); + + const [, payload] = calls[0] as [string, any]; + expect(payload).toEqual(expect.objectContaining({ + deviceId: disconnectedV1DeviceId, + deviceModel: 'testDeviceV1', + provider: SlvCtrlPlusSerialDeviceProvider.providerName, + })); + }); + + it('removes disconnected device from GET /devices', async () => { + const devicesBeforeRes = await request(app.instance).get('/devices'); + expect(devicesBeforeRes.status).toBe(200); + const countBefore = devicesBeforeRes.body.count; + + const device = getDevice('testDeviceLegacy'); + disconnectedLegacyDeviceId = device.getDeviceId; + + const disconnectPromise = waitForDeviceDisconnected(container, disconnectedLegacyDeviceId); + await device.close(); + await disconnectPromise; + + const devicesAfterRes = await request(app.instance).get('/devices'); + expect(devicesAfterRes.status).toBe(200); + expect(devicesAfterRes.body.count).toBe(countBefore - 1); + + const deviceIds = devicesAfterRes.body.items.map((item: any) => item.deviceId); + expect(deviceIds).not.toContain(disconnectedLegacyDeviceId); + }); - expect(await device.setAttribute('intensity', Int.from(50))).toBe(50); - expect(serialPortSimulator.getValue('intensity')).toBe('50'); + it('returns 404 for disconnected device on GET /device/:id', async () => { + const devices = container.get('device.manager').getConnectedDevices(); + expect(devices).toHaveLength(0); + + // Verify both disconnected devices return 404 + const v1Res = await request(app.instance).get(`/device/${disconnectedV1DeviceId}`); + expect(v1Res.status).toBe(404); - expect(await device.setAttribute('preset', 'high')).toBe('high'); - expect(serialPortSimulator.getValue('preset')).toBe('high'); + const legacyRes = await request(app.instance).get(`/device/${disconnectedLegacyDeviceId}`); + expect(legacyRes.status).toBe(404); }); }); });