diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5b35aab --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +.git +.github +.vscode +.idea +*.log +coverage +docs +examples +images +*.md +.dockerignore +.gitignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b478068 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,134 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + smoke: + name: Syntax + module load + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - run: npm ci + + # node --check on every committed JS file. Catches the trivial breakage + # class (typos, unmatched braces) that has no other guard in this repo. + - name: Syntax check + run: | + set -euo pipefail + # Exclude node_modules and the vendored bacstack dist (precompiled). + mapfile -t files < <(git ls-files '*.js' \ + | grep -v '^node_modules/' \ + | grep -v '^resources/node-bacstack-ts/dist/') + echo "checking ${#files[@]} files" + for f in "${files[@]}"; do node --check "$f"; done + + - name: Smoke (require-safe modules) + run: npm run test:unit + + integration: + name: Node-RED + edge-bacnet vs bacnet-sim-ci + runs-on: ubuntu-latest + needs: smoke + # Pattern from rise-building-technology/bacnet-sim-ci-test: a custom Docker + # network with explicit container IPs for both sim and client. Both run as + # peers on the same /24 — no docker-proxy NAT, no runner-host bridge IP + # confusion. The earlier services:/host-runner/docker-run-on-host topologies + # all lost the BACnet UDP response packet across the docker-bridge boundary. + steps: + - uses: actions/checkout@v4 + + - name: Create test network (172.20.0.0/24) + run: docker network create --subnet=172.20.0.0/24 bacnet-test-net + + # Pinned by digest for deterministic CI. The upstream has no tagged releases yet + # (only a rolling :latest), so we capture a known-good digest here. Bump after + # verifying a newer image works locally. + - name: Pull and start bacnet-sim + env: + SIM_IMAGE: ghcr.io/rise-building-technology/bacnet-sim-ci@sha256:29ed481aa6015dc3508a5aec5b2cb5a69c86bdc2ef22bb7ecb0d75c0d7745963 + run: | + set -e + docker pull "$SIM_IMAGE" + docker run -d \ + --name bacnet-sim \ + --network bacnet-test-net \ + --ip 172.20.0.10 \ + --cap-add=NET_ADMIN \ + -e BACNET_DEVICE_ID=1001 \ + -e BACNET_DEVICE_NAME=TestDevice \ + "$SIM_IMAGE" + for i in $(seq 1 60); do + if docker exec bacnet-sim curl -sf http://localhost:8099/health/ready >/dev/null 2>&1; then + echo "sim ready (${i}s)" + docker exec bacnet-sim curl -s http://localhost:8099/api/devices + break + fi + if [ "$i" -eq 60 ]; then + echo "ERROR: sim never became ready" + docker logs bacnet-sim + exit 1 + fi + sleep 1 + done + + - name: Build Node-RED + edge-bacnet image + run: docker build -f tests/integration/Dockerfile -t nodered-edgebacnet:test . + + - name: Start Node-RED + run: | + docker run -d \ + --name nodered \ + --network bacnet-test-net \ + --ip 172.20.0.20 \ + nodered-edgebacnet:test + + # The pre-loaded flow has a Bacnet-Gateway with discover_polling_schedule=5s + # and toLogIam=true. After Node-RED finishes booting and the gateway issues + # its first Who-Is, the sim should respond with I-Am for device 1001 and + # the gateway will log "BACnet device found: 1001 - 172.20.0.10". + - name: Wait for edge-bacnet to discover the sim (device 1001) + run: | + set -e + for i in $(seq 1 90); do + if docker logs nodered 2>&1 | grep -q "BACnet device found: 1001"; then + echo "==> Discovery succeeded after ${i}s" + docker logs nodered 2>&1 | grep "BACnet device found" | head -5 + exit 0 + fi + sleep 1 + done + echo "==> ERROR: Did not see 'BACnet device found: 1001' in node-red logs after 90s" + echo "--- node-red logs ---" + docker logs nodered + echo "--- bacnet-sim logs ---" + docker logs bacnet-sim + exit 1 + + - name: Diagnostics on failure + if: failure() + run: | + echo "=== docker ps ===" + docker ps -a + echo "=== node-red logs ===" + docker logs nodered 2>&1 | tail -200 || true + echo "=== bacnet-sim logs ===" + docker logs bacnet-sim 2>&1 | tail -200 || true + + - name: Cleanup + if: always() + run: | + docker rm -f nodered bacnet-sim 2>/dev/null || true + docker network rm bacnet-test-net 2>/dev/null || true diff --git a/package.json b/package.json index fc733d5..433327e 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "@bitpoolos/edge-bacnet", "version": "1.6.8", "description": "A bacnet gateway for node-red", + "scripts": { + "test:unit": "node tests/unit/smoke.js" + }, "dependencies": { "@plus4nodered/ts-node-bacnet": "^1.0.0-beta.2", "@vue/server-renderer": "^3.5.13", diff --git a/tests/integration/Dockerfile b/tests/integration/Dockerfile new file mode 100644 index 0000000..10e7681 --- /dev/null +++ b/tests/integration/Dockerfile @@ -0,0 +1,28 @@ +# Node-RED with this branch's @bitpoolos/edge-bacnet installed as a palette. +# Built from the repo root: `docker build -f tests/integration/Dockerfile -t .` +# The build context is the whole repo so we can `npm install` edge-bacnet from local source. +# Pinned by digest for deterministic CI. Bump after verifying a newer image works. +FROM nodered/node-red:latest@sha256:a5cb1dcdf90a7148b02b9dba4acfe6bd98c7bf3bff1e845dcdecfd7af3a95a29 + +USER root +# Copy the source we want to install (the .dockerignore at the repo root keeps +# node_modules / .git / docs out of the layer). +COPY --chown=node-red:node-red . /tmp/edge-bacnet +# Pre-loaded flow with a single Bacnet-Gateway configured to discover devices on +# the test Docker network. With toLogIam=true the gateway will log +# "BACnet device found: - " when it sees an I-Am — that's what +# the integration step asserts on. +COPY --chown=node-red:node-red tests/integration/flows.json /data/flows.json + +# Install edge-bacnet AND its runtime deps into /data/node_modules — that's the +# standard Node-RED palette location and avoids permission issues writing to +# /usr/src/node-red. Run as the node-red user so the resulting tree is owned +# correctly at runtime. Don't change WORKDIR — the base image's entrypoint +# (./entrypoint.sh) is relative to /usr/src/node-red. +USER node-red +# --install-links forces npm to TAR + COPY the local package (and resolve its +# runtime deps into /data/node_modules) instead of symlinking to /tmp/edge-bacnet. +# Without this, Node-RED loads edge-bacnet from /tmp via the symlink, looks for +# toad-scheduler etc. relative to /tmp/edge-bacnet/node_modules (which doesn't +# exist), and fails to register the palette. +RUN cd /data && npm install --no-audit --no-fund --install-links /tmp/edge-bacnet diff --git a/tests/integration/flows.json b/tests/integration/flows.json new file mode 100644 index 0000000..5fd36f8 --- /dev/null +++ b/tests/integration/flows.json @@ -0,0 +1,57 @@ +[ + { + "id": "f1", + "type": "tab", + "label": "Integration Test", + "disabled": false, + "info": "" + }, + { + "id": "gateway1", + "type": "Bacnet-Gateway", + "z": "f1", + "name": "test-gateway", + "local_device_address": "0.0.0.0", + "local_interface_name": "", + "apduTimeout": 6000, + "maxConcurrentRequests": 250, + "roundDecimal": 2, + "local_device_port": 47808, + "apduSize": "5", + "maxSegments": "0x50", + "retries": "5", + "broadCastAddr": "172.20.0.255", + "toLogIam": true, + "discover_polling_schedule": "5", + "discover_polling_schedule_value": "5", + "discover_polling_schedule_options": "Seconds", + "deviceId": 9999, + "logErrorToConsole": true, + "serverEnabled": false, + "device_read_schedule": "30", + "device_read_schedule_value": "30", + "device_read_schedule_options": "Seconds", + "deviceRangeRegisters": [ + { + "enabled": true, + "start": "0", + "end": "4194303" + } + ], + "portRangeRegisters": [ + { + "enabled": true, + "start": "47808", + "end": "47808" + } + ], + "cacheFileEnabled": false, + "sanitise_device_schedule": "60", + "sanitise_device_schedule_value": "1", + "sanitise_device_schedule_options": "Hours", + "enable_device_discovery": true, + "x": 200, + "y": 100, + "wires": [[]] + } +] diff --git a/tests/unit/smoke.js b/tests/unit/smoke.js new file mode 100644 index 0000000..e126c1a --- /dev/null +++ b/tests/unit/smoke.js @@ -0,0 +1,55 @@ +/* + * Lightweight smoke tests for the require-safe modules. Catches breakage that + * `node --check` (syntax-only) misses — e.g. a require chain that fails at load + * time, or a renamed export. + * + * The Node-RED factory modules (bacnet_gateway, bacnet_read, bacnet_write, + * bacnet_inspector, bitpool_inject) export `function(RED)` and cannot be + * exercised here without a fake RED runtime. They are covered by `node --check` + * in CI for syntax validity only. + */ + +let pass = 0; +let fail = 0; +function ok(name, cond, info) { + if (cond) { pass++; console.log(` ok ${name}`); return; } + fail++; + console.log(` FAIL ${name}${info ? ' ' + info : ''}`); +} + +// ---- vendored bacstack loads and exposes the constants we depend on --------- +const bacnet = require('../../resources/node-bacstack-ts/dist/index.js'); +ok('bacstack: enum.PropertyIdentifier present', typeof bacnet.enum.PropertyIdentifier === 'object'); +ok('bacstack: PRESENT_VALUE === 85', bacnet.enum.PropertyIdentifier.PRESENT_VALUE === 85); +ok('bacstack: ObjectType.ANALOG_VALUE present', typeof bacnet.enum.ObjectType.ANALOG_VALUE === 'number'); +ok('bacstack: Client constructor exported', typeof bacnet.Client === 'function'); + +// ---- common --------------------------------------------------------------- +const common = require('../../common.js'); +ok('common: exports object', typeof common === 'object'); +ok('common: Read_Config_Sync_Server is callable', typeof common.Read_Config_Sync_Server === 'function'); +ok('common: Store_Config_Server is callable', typeof common.Store_Config_Server === 'function'); + +// ---- BacnetServer can be required and the class shape is what we expect ---- +const { BacnetServer } = require('../../bacnet_server.js'); +ok('BacnetServer: class exported', typeof BacnetServer === 'function'); +ok('BacnetServer: addObject on prototype', typeof BacnetServer.prototype.addObject === 'function'); +ok('BacnetServer: getObject on prototype', typeof BacnetServer.prototype.getObject === 'function'); +ok('BacnetServer: getServerPoints on prototype', typeof BacnetServer.prototype.getServerPoints === 'function'); + +// ---- BacnetDevice and treeBuilder load ------------------------------------- +const { BacnetDevice } = require('../../bacnet_device.js'); +ok('BacnetDevice: class exported', typeof BacnetDevice === 'function'); + +const { treeBuilder } = require('../../treeBuilder.js'); +ok('treeBuilder: function exported', typeof treeBuilder === 'function'); + +// ---- BacnetClient class shape --------------------------------------------- +// Don't construct it (the constructor binds a UDP socket and starts schedulers). +const { BacnetClient } = require('../../bacnet_client.js'); +ok('BacnetClient: class exported', typeof BacnetClient === 'function'); +ok('BacnetClient: doRead on prototype', typeof BacnetClient.prototype.doRead === 'function'); +ok('BacnetClient: doWrite on prototype', typeof BacnetClient.prototype.doWrite === 'function'); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail === 0 ? 0 : 1);