From 629ce923fed4aa9cf8321250cb38c1aaa8ae237f Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 6 Jun 2026 22:06:20 +0200 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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}`); } }