diff --git a/.github/workflows/cocogitto.yml b/.github/workflows/check-commit-pr.yml similarity index 95% rename from .github/workflows/cocogitto.yml rename to .github/workflows/check-commit-pr.yml index b546705..75b72ef 100644 --- a/.github/workflows/cocogitto.yml +++ b/.github/workflows/check-commit-pr.yml @@ -3,7 +3,7 @@ # to keep things as portable as possible and to easily replicate the # workflow locally. -name: check commit messages +name: Check commit messages and PR titles on: push: @@ -27,4 +27,3 @@ jobs: - name: Check PR title using cocogitto if: github.event_name == 'pull_request' run: docker run -t ghcr.io/cocogitto/cog:latest verify "${{ github.event.pull_request.title }}" - \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 43bf485..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,32 +0,0 @@ -# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions - -name: Node.js CI - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - test: - - strategy: - matrix: - os: [ubuntu-latest] - node-version: [20.x, 22.x, 24.x] - arch: [x64] - - runs-on: ${{ matrix.os }} - name: ${{ matrix.os }} / Node ${{ matrix.node-version }} ${{ matrix.arch }} - - steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2 - with: - node-version: ${{ matrix.node-version }} - - run: npm ci - - run: npm run build - - run: npm test diff --git a/.github/workflows/tests-end-to-end.yml b/.github/workflows/tests-end-to-end.yml new file mode 100644 index 0000000..ed3088f --- /dev/null +++ b/.github/workflows/tests-end-to-end.yml @@ -0,0 +1,64 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: E2E Test Suite + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + + strategy: + matrix: + os: [ubuntu-latest] + node-version: [20.x] + arch: [x64] + + runs-on: ${{ matrix.os }} + name: ${{ matrix.os }} / Node ${{ matrix.node-version }} ${{ matrix.arch }} + + steps: + + - name: Checkout code + uses: actions/checkout@v2 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + + - name: Cache bacnet-stack-server image + uses: actions/cache@v4 + id: cache-bacnet-stack-server-docker-image + with: + path: bacnet-stack-server.tar + key: ${{ runner.os }}-${{ hashFiles('docker/bacnet-stack-server/*') }} + + - name: Load cached bacnet-stack-server image + if: ${{ steps.cache-bacnet-stack-server-docker-image.outputs.cache-hit == 'true' }} + continue-on-error: false + run: docker load -i bacnet-stack-server.tar + + - name: Build bacnet-stack-server image + if: ${{ steps.cache-bacnet-stack-server-docker-image.outputs.cache-hit != 'true' }} + continue-on-error: false + run: docker build -t bacnet-stack-server --platform linux/amd64 docker/bacnet-stack-server && docker save -o bacnet-stack-server.tar bacnet-stack-server + + - name: Install dependencies + run: npm ci + + - name: Build project + run: npm run build + + - name: Prepare environment for E2E tests + run: sh e2e-tests-prep.sh + + - name: Run E2E tests + run: sh e2e-tests-run.sh + + - name: Clean up environment after E2E tests + run: sh e2e-tests-clean.sh diff --git a/README.md b/README.md index 69de3bb..9e875b7 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,10 @@ able to run locally and use with any BACnet client of your choice. For local testing I tend to use [YABE], which is native to Windows but can be made to work reasonably well on macOS and Linux via [Wine]. +## Testing + +See [TESTING.md]. + [device]: https://github.com/bacnet-js/device [apidocs]: https://bacnet-js.github.io/device @@ -68,6 +72,7 @@ work reasonably well on macOS and Linux via [Wine]. [@bacnet-js/client]: https://github.com/bacnet-js/node-bacnet [LICENSE]: https://github.com/bacnet-js/device/blob/main/LICENSE [CONFORMANCE.md]: https://github.com/bacnet-js/device/blob/main/CONFORMANCE.md +[TESTING.md]: https://github.com/bacnet-js/device/blob/main/TESTING.md [examples]: https://github.com/bacnet-js/device/tree/main/src/examples [YABE]: https://sourceforge.net/projects/yetanotherbacnetexplorer/ [Wine]: https://www.winehq.org diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..621d209 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,34 @@ + +# Testing + +## End-to-end tests + +E2E tests use [bacnet-stack] as a third-party implementation of the BACnet +protocol. + +The suite depends on Docker to run [bacnet-stack]'s CLI programs in a separate +container from that of the BACnet device implemented through this library. This +is necessary because, in practice, it's very hard to get different BACnet +devices to share the same network interface. Docker offers us an easy way to +ensure that each device runs on its dedicated (virtual) interface. + +## Building the `bacnet-stack` image + +The image used for the [bacnet-stack] container, whose Dockerfile is located at +`./docker/docker-stack-server`, includes a simple HTTP server through which the +other container (which runs the test suite) can trigger BACnet queries. Build +the image as follows before running the E2E test suite: + +```sh +docker build -t bacnet-stack-server --platform linux/amd64 docker/bacnet-stack-server +``` + +## Running the E2E test suite + +```sh +sh e2e-tests-prep.sh +sh e2e-tests-run.sh +sh e2e-tests-clean.sh +``` + +[bacnet-stack]: https://github.com/bacnet-stack/bacnet-stack diff --git a/docker/bacnet-stack-server/Dockerfile b/docker/bacnet-stack-server/Dockerfile new file mode 100644 index 0000000..f2c42d6 --- /dev/null +++ b/docker/bacnet-stack-server/Dockerfile @@ -0,0 +1,12 @@ + +FROM node:20-alpine AS build-env +RUN apk add --no-cache build-base curl git linux-headers +WORKDIR / +RUN git clone https://github.com/bacnet-stack/bacnet-stack.git --depth 1 --branch bacnet-stack-1.4.1 +RUN cd bacnet-stack && make clean all + +FROM node:20-alpine +COPY --from=build-env /bacnet-stack /bacnet-stack +WORKDIR /bacnet-stack +COPY ./server.js /server.js +CMD ["node", "/server.js"] diff --git a/docker/bacnet-stack-server/server.js b/docker/bacnet-stack-server/server.js new file mode 100644 index 0000000..56ac967 --- /dev/null +++ b/docker/bacnet-stack-server/server.js @@ -0,0 +1,30 @@ + +import { createServer } from 'node:http'; +import { exec } from 'node:child_process'; + +const server = createServer((req, res) => { + let buf = Buffer.alloc(0); + req.on('data', (chunk) => { + buf = Buffer.concat([buf, chunk]); + }); + req.on('end', () => { + let cmd = buf.toString(); + if (cmd.length === 0 || !cmd.startsWith('bac')) { + res.statusCode = 400; + res.end('Invalid command'); + return; + } + cmd = `/bacnet-stack/bin/${cmd}`; + exec(cmd, (err, stdout, stderr) => { + if (err) { + res.statusCode = 500; + res.end(err.stack ?? err); + } else { + res.statusCode = 200; + res.end(stdout.trim()); + } + }); + }); +}); + +server.listen(3000, '0.0.0.0'); diff --git a/e2e-tests-clean.sh b/e2e-tests-clean.sh new file mode 100644 index 0000000..7dea138 --- /dev/null +++ b/e2e-tests-clean.sh @@ -0,0 +1,3 @@ + +docker stop bacnet-stack-server && docker rm bacnet-stack-server +docker network rm bacnet-js diff --git a/e2e-tests-prep.sh b/e2e-tests-prep.sh new file mode 100644 index 0000000..2f65417 --- /dev/null +++ b/e2e-tests-prep.sh @@ -0,0 +1,5 @@ + + +docker network create bacnet-js + +docker run -d --platform linux/amd64 --network bacnet-js --name bacnet-stack-server bacnet-stack-server diff --git a/e2e-tests-run.sh b/e2e-tests-run.sh new file mode 100644 index 0000000..baf9c15 --- /dev/null +++ b/e2e-tests-run.sh @@ -0,0 +1,8 @@ + +docker run \ + --rm \ + --volume ./:/app \ + --workdir /app \ + --network bacnet-js \ + node:20-alpine \ + node --enable-source-maps --test dist diff --git a/package.json b/package.json index 42f6b2b..85d4518 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "clean": "rm -rf ./dist", "build": "npm run clean && tsc -p .", "hooks": "cog install-hook --all", - "test": "echo \"done\"" + "test": "echo \"see TESTING.md\"" }, "author": "Jacopo Scazzosi ", "license": "MIT", @@ -39,6 +39,7 @@ "dist/**/*.js", "dist/**/*.d.ts", "!dist/examples/**/*", + "!dist/tests/**/*", "CONFORMANCE.md" ], "keywords": [ diff --git a/src/objects/device/device.ts b/src/objects/device/device.ts index 5ef9804..353c1b6 100644 --- a/src/objects/device/device.ts +++ b/src/objects/device/device.ts @@ -402,8 +402,10 @@ export class BDDevice extends BDObject implements AsyncEventEmitter { + + let device: BDDevice; + + beforeEach(async () => { + device = new BDDevice(1, { + name: 'Test Device', + }); + device.on('error', console.error); + device.addObject(new BDAnalogValue({ + name: 'Test Value', + unit: EngineeringUnits.AMPERES, + presentValue: 0, + })); + }); + + afterEach(async () => { + device.destroy(); + }) + + it('should read the object\'s Present_Value property', async () => { + const value = await bsReadProperty(1, ObjectType.ANALOG_VALUE, 1, PropertyIdentifier.PRESENT_VALUE); + deepStrictEqual(parseInt(value), 0); + }); + + it('should read the object\'s Units property', async () => { + const value = await bsReadProperty(1, ObjectType.ANALOG_VALUE, 1, PropertyIdentifier.UNITS); + deepStrictEqual(value, 'amperes'); + }); + +}); diff --git a/src/tests/bacnet-stack-client.ts b/src/tests/bacnet-stack-client.ts new file mode 100644 index 0000000..0af1308 --- /dev/null +++ b/src/tests/bacnet-stack-client.ts @@ -0,0 +1,19 @@ + +import { ObjectType, PropertyIdentifier } from '@bacnet-js/client'; + +const bsExec = async (bin: string, args: string[]) => { + const res = await fetch('http://bacnet-stack-server:3000', { + method: 'POST', + body: `${bin} ${args.map(a => `"${a}"`).join(' ')}`, + }); + if (res.ok) { + const stdout = await res.text(); + return stdout; + } + const err = await res.text(); + throw new Error(err); +}; + +export const bsReadProperty = async (devIn: number, objType: ObjectType, objIn: number, propId: PropertyIdentifier) => { + return await bsExec('bacrp', [`${devIn}`, `${objType}`, `${objIn}`, `${propId}`]); +};