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 8e3b4baf..9463c96e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,9 +46,12 @@ jobs: - name: Typecheck run: | npm run typecheck - - name: Coverage + - name: Unit tests with coverage run: | - npm run coverage + npm run test: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/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', ], }, 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..10eaf73a 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:unit": "vitest run --config vitest.config.ts", + "test:integration": "vitest run --config vitest.config.integration.ts", + "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" 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 } - diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 00000000..0813439f --- /dev/null +++ b/src/app.ts @@ -0,0 +1,215 @@ +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 HealthServiceProvider from './serviceProvider/healthServiceProvider.js'; +import { setIntervalAsync } from './util/async.js'; +import { logError } from './util/error.js'; + +export type { SslConfig }; + +export interface AppOptions { + allowedOrigins?: string[]; + sslConfig?: SslConfig; + dataPath: string; +} + +export interface AppInstance { + expressApp: express.Application; + container: Pimple; + listen: (httpPort: number, httpsPort?: number) => void; +} + +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')); + + 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')); +} + +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: start background refresh, then broadcast cached value on each tick + healthMetricsCollector.start(1000); + + setIntervalAsync(async () => { + const metrics = healthMetricsCollector.collect(); + if (metrics !== null) { + io.emit(WebSocketEvent.healthMetrics, metrics); + } + }, { + intervalMs: 500, + timeoutMs: 1_000, + onError: (err) => logError(logger, 'Health metrics broadcast failed', err), + }); + + // 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)); + }); + + 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)); +}; + +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'); + + 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)); +}; + +export const createApp = (options: AppOptions): AppInstance => { + const { allowedOrigins = [], sslConfig, dataPath } = options; + + const corsOptions: CorsOptions = { + origin: (origin, callback) => { + if (undefined === origin || allowedOrigins.length === 0) { + return callback(null, true); + } + + return callback(null, allowedOrigins.includes(origin)); + }, + }; + + const app = express(); + const container = new Pimple(); + + container + .register(new LoggerServiceProvider()) + .register(new HealthServiceProvider()) + .register(new ServerServiceProvider(app, corsOptions, sslConfig)) + .register(new SettingsServiceProvider(dataPath)) + .register(new DeviceServiceProvider()) + .register(new ControllerServiceProvider()) + .register(new SocketServiceProvider()) + .register(new RepositoryServiceProvider(dataPath)) + .register(new SerializationServiceProvider()) + .register(new AutomationServiceProvider(dataPath)) + .register(new FactoryServiceProvider()) + .register(new SchemaValidationServiceProvider()) + ; + + // Middlewares + app + .use((req, res, next) => { + // Required for PNA preflight until https://github.com/expressjs/cors/pull/274 is merged + 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, + 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..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,7 +182,7 @@ export class ScriptRuntime private eventQueue: (() => Promise)[] = []; - private processingQueue = false; + private processQueuePromise: Promise | null = null; public constructor(deviceRepository: DeviceRepositoryInterface, logPath: string, eventEmitter: EventEmitter, logger: Logger) { this.eventEmitter = eventEmitter; @@ -296,8 +296,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 +329,6 @@ export class ScriptRuntime } } - this.dispatchRef = null; this.lifecycleRef = null; this.pendingEventDone = null; this.pendingLifecycleDone = null; @@ -366,15 +380,13 @@ export class ScriptRuntime ); })); - if (!this.processingQueue) { - void this.processQueue(); + 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 { @@ -387,7 +399,7 @@ export class ScriptRuntime } } - this.processingQueue = false; + this.processQueuePromise = null; } public async getLog(maxLines: number): Promise @@ -417,6 +429,10 @@ export class ScriptRuntime this.eventEmitter.on(event, listener); return this; } -} -export default ScriptRuntime; + public off (event: E, listener: ScriptRuntimeEvents[E]): this + { + this.eventEmitter.off(event, listener); + return this; + } +} 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/device/deviceManager.ts b/src/device/deviceManager.ts index e2a9f96a..81e4c5ba 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -149,6 +149,25 @@ 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 this.connectedDevices) { + await device.close(); + } + + for (const [deviceId] of this.detectedDeviceAcquireQueue) { + this.clearDetectedDeviceAcquireQueue(deviceId, 'Device manager reset'); + } + } + private clearDetectedDeviceAcquireQueue(deviceId: string, reason: string): void { for (const entry of this.detectedDeviceAcquireQueue.get(deviceId) ?? []) { 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..4806d93a 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 async stop(): Promise { + 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..a3e6a419 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 > 0 + ? 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..c49d3e0b 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 async stop(): Promise { + return Promise.resolve(); + } } diff --git a/src/device/provider/deviceProviderLoader.ts b/src/device/provider/deviceProviderManager.ts similarity index 65% rename from src/device/provider/deviceProviderLoader.ts rename to src/device/provider/deviceProviderManager.ts index 63cf5799..fd11f116 100644 --- a/src/device/provider/deviceProviderLoader.ts +++ b/src/device/provider/deviceProviderManager.ts @@ -2,37 +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 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)`); @@ -46,7 +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(); } } + + public async stopProviders(): Promise { + for (const provider of this.providers) { + await provider.stop(); + } + } } diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 00000000..7e9c26ac --- /dev/null +++ b/src/env.ts @@ -0,0 +1,32 @@ +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 }), + HTTPS_PORT: Type.Number({ default: 1338, minimum: 1, maximum: 65535 }), + 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; + +export const parseEnv = (env: NodeJS.ProcessEnv): Env => { + const converted = Value.Default(EnvSchema, 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')}`); + } + + 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/src/health/healthMetricsCollector.ts b/src/health/healthMetricsCollector.ts index 9ba924c4..9d17a5c0 100644 --- a/src/health/healthMetricsCollector.ts +++ b/src/health/healthMetricsCollector.ts @@ -1,6 +1,9 @@ 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: { @@ -39,15 +42,45 @@ export default class HealthMetricsCollector { private readonly osUtils: OSUtils; - public constructor() + private readonly logger: Logger; + + private currentMetrics: HealthMetrics | null = null; + + private intervalHandle: IntervalAsync | null = null; + + public constructor(logger: Logger) { + this.logger = logger; this.osUtils = new OSUtils({ cacheEnabled: true, cacheTTL: 60_000, }); } - public async collect(): Promise + public start(intervalMs: number): void + { + if (this.intervalHandle !== null) { + return; + } + + this.intervalHandle = setIntervalAsync( + async () => await this.refresh(), + { intervalMs, timeoutMs: intervalMs * 3, onError: (err) => logError(this.logger, `Health metrics refresh failed`, err) }, + ); + } + + 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 +92,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 0b86f7f7..398136e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,191 +1,31 @@ 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 { logError } from './util/error.js'; -import { setIntervalAsync } from './util/async.js'; -import HealthServiceProvider from './serviceProvider/healthServiceProvider.js'; +import { createApp } from './app.js'; +import { SslConfig } from './serviceProvider/serverServiceProvider.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 && env.ALLOWED_ORIGINS.length > 0 + ? 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 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 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'); - -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 app = createApp({ + allowedOrigins, + sslConfig, + dataPath: env.DATA_PATH, }); -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()); -}, { - intervalMs: 500, - timeoutMs: 2_000, - 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}`); -}); - -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/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}`); } } 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/automationServiceProvider.ts b/src/serviceProvider/automationServiceProvider.ts index 33e2b048..7ba75f15 100644 --- a/src/serviceProvider/automationServiceProvider.ts +++ b/src/serviceProvider/automationServiceProvider.ts @@ -1,17 +1,22 @@ 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); + fs.mkdirSync(logPath, { recursive: true }); } return new ScriptRuntime( 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/src/serviceProvider/healthServiceProvider.ts b/src/serviceProvider/healthServiceProvider.ts index 3889ddc8..c81f90ed 100644 --- a/src/serviceProvider/healthServiceProvider.ts +++ b/src/serviceProvider/healthServiceProvider.ts @@ -5,6 +5,8 @@ import HealthMetricsCollector from '../health/healthMetricsCollector.js'; export default class HealthServiceProvider 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 b0092c17..e29fd9bf 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,10 +20,10 @@ 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); + fs.mkdirSync(scriptsPath, { recursive: true }); } return new AutomationScriptRepository(scriptsPath); diff --git a/src/serviceProvider/settingsServiceProvider.ts b/src/serviceProvider/settingsServiceProvider.ts index 0ed78a56..08f64ae3 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 dataPath: string | undefined; + + public constructor(dataPath?: string) { + this.dataPath = dataPath; + } + public register(container: Pimple): void { container.set('settings.schema.validator', () => { const jsonSchemaValidatorFactory = container.get('factory.validator.schema.json'); @@ -19,14 +25,16 @@ export default class SettingsServiceProvider implements ServiceProvider { - const settingsPath = `${os.homedir()}/.slvctrlplus/`; + const dataPath = this.dataPath ?? `${os.homedir()}/.slvctrlplus`; - if (false === fs.existsSync(settingsPath)) { - fs.mkdirSync(settingsPath); + if (false === fs.existsSync(dataPath)) { + fs.mkdirSync(dataPath, { recursive: true }); } + const settingsFilePath = `${dataPath}/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/api.spec.ts b/tests/integration/api.spec.ts new file mode 100644 index 00000000..41efd0ad --- /dev/null +++ b/tests/integration/api.spec.ts @@ -0,0 +1,286 @@ +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); + }); + + 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/automationScripts.spec.ts b/tests/integration/automationScripts.spec.ts new file mode 100644 index 00000000..f1898682 --- /dev/null +++ b/tests/integration/automationScripts.spec.ts @@ -0,0 +1,233 @@ +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'; + +const 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); + }); +} + +const waitForEvent = (scriptRuntime: ScriptRuntime, eventType: AutomationEventType): Promise => { + return new Promise((resolve, reject) => { + 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); + }) +}; + +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); + }, 2000); + + describe('Script lifecycle via REST API', () => { + it('POST /automation/run loads the script and reports running status', async () => { + const scriptRuntime = instance.container.get('automation.scriptRuntime'); + + const scriptStarted = waitForEvent(scriptRuntime, AutomationEventType.scriptStarted); + + 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 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 () => { + const scriptRuntime = instance.container.get('automation.scriptRuntime'); + + await request(instance.expressApp) + .post('/automation/run') + .set('Content-Type', 'text/plain') + .send('onEvent(() => {});'); + + const scriptStopped = waitForEvent(scriptRuntime, AutomationEventType.scriptStopped); + + const stopRes = await request(instance.expressApp).get('/automation/stop'); + 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; + + await scriptRuntime.stop(); + + 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}'); + }); + `); + + // 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); + }); + + 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..3ef395a0 --- /dev/null +++ b/tests/integration/deviceEvents.spec.ts @@ -0,0 +1,143 @@ +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'; +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); + + const listener = async () => { + const value = (await device.getAttribute('value'))?.value; + + if (undefined === observedValue) { + observedValue = value; + return; + } + + if (value !== observedValue) { + changedValue = value; + clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceRefreshed, listener); + resolve(); + } + }; + deviceManager.on(DeviceManagerEvent.deviceRefreshed, listener); + }); + + 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); + 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 () => { + 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); + + 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(); + 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..64bdf423 --- /dev/null +++ b/tests/integration/helpers/appHelper.ts @@ -0,0 +1,136 @@ +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 } }; + +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]: { + id: TEST_SOURCE_ID, + type: 'virtual', + config: { scanIntervalMs: 50 }, + }, + }, +}; + +export const createTestApp = async (): Promise<{ instance: AppInstance, tmpDir: string }> => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'slvctrlplus-test-')); + const dataPath = tmpDir + path.sep; + fs.writeFileSync(path.join(tmpDir, 'settings.json'), JSON.stringify(baseSettingsJson)); + + const instance = createApp({ dataPath }); + + 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').stopProviders(); + + 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 cleanup = () => { + deviceManager.off(DeviceManagerEvent.deviceDisconnected, listener); + clearTimeout(timeout); + }; + const timeout = setTimeout( + () => { + 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) { + cleanup(); + 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/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'; 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 new file mode 100644 index 00000000..895346b4 --- /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/**/*.spec.ts"], + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 96a3abbd..f80b7830 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { environment: "node", setupFiles: ["./tests/vitest.setup.ts"], + include: ["tests/unit/**/*.spec.ts"], coverage: { provider: "v8", reporter: ["text", "html", "lcov"],