From 3cb0ecb9a8011c526a684257f4ad34c899f48ae4 Mon Sep 17 00:00:00 2001 From: Robin Bolscher Date: Tue, 27 Oct 2026 12:22:35 +0100 Subject: [PATCH 1/8] test: Add a virtual device harness for misbehaving-device edge cases Real devices behave too well to reproduce bugs like the readAttributes retry loop. createVirtualDevice puts the device on its own node behind an in-memory link that records every frame, enforces Homey's 82-byte ZCL frame limit, and lets a test drop, rewrite or inject frames. test/virtualDevice.js keeps four bugs that reproduce on master as todo tests: the readAttributes loop on unrequested ids (#258), readAttributes([]) on metering overflowing a frame (#259), Endpoint.unbind leaving a Cluster in bindings, and attr.undefined emitted for unknown ids in a report. Refs #258 Refs #259 --- AGENTS.md | 9 +- test/util/index.js | 3 + test/util/virtualDevice.js | 126 ++++++++++++++++++++++++++ test/virtualDevice.js | 180 +++++++++++++++++++++++++++++++++++++ 4 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 test/util/virtualDevice.js create mode 100644 test/virtualDevice.js diff --git a/AGENTS.md b/AGENTS.md index d2151f8f..c972bde5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,8 @@ Build mock nodes with the helpers in `test/util/index.js` instead of by hand: - `createConnectedNodePair()` wires two nodes together when sender and receiver must differ. See `test/testNode.js`. - `MOCK_DEVICES` holds preset sensors. See `test/iasZone.js`. - -Server-to-client traffic such as attribute reports and notifications has no helper at the time of -writing, so check `test/util/index.js` first and otherwise build a `ZCLStandardHeader` by hand and -pass it to `node.handleFrame()`, as `test/attributeReport.js` does. +- `createVirtualDevice()` for a misbehaving device: a separate device node behind a link that + records every frame, enforces Homey's 82-byte frame limit and lets an interceptor drop, rewrite + or duplicate frames. Device-initiated frames such as attribute reports go out with + `virtualDevice.sendFrame()`. See `test/virtualDevice.js`, which also keeps known bugs as `todo` + tests; a fix removes the `todo`. diff --git a/test/util/index.js b/test/util/index.js index dadbea9c..9783fe95 100644 --- a/test/util/index.js +++ b/test/util/index.js @@ -10,6 +10,7 @@ const { createBoundClusterWithAttributes, MOCK_DEVICES, } = require('./mockNode'); +const { createVirtualDevice, HOMEY_MAX_ZCL_BYTES } = require('./virtualDevice'); module.exports = { debug, @@ -18,4 +19,6 @@ module.exports = { createConnectedNodePair, createBoundClusterWithAttributes, MOCK_DEVICES, + createVirtualDevice, + HOMEY_MAX_ZCL_BYTES, }; diff --git a/test/util/virtualDevice.js b/test/util/virtualDevice.js new file mode 100644 index 00000000..b2ac4a43 --- /dev/null +++ b/test/util/virtualDevice.js @@ -0,0 +1,126 @@ +'use strict'; + +const Node = require('../../lib/Node'); +const Endpoint = require('../../lib/Endpoint'); +const BoundCluster = require('../../lib/BoundCluster'); +const { createBoundClusterWithAttributes } = require('./mockNode'); + +// Homey's Zigbee stack rejects a longer ZCL frame (MAX_ZCL_BYTES in @athombv/z3gateway). +const HOMEY_MAX_ZCL_BYTES = 82; + +/** + * Creates a virtual device and the ZCLNode a driver would get for it, connected by an in-memory + * link that records every frame and lets a test drop, rewrite or inject frames. + * + * @example + * const { zclNode, virtualDevice, frames, intercept, settle } = createVirtualDevice({ + * endpoints: [{ + * endpointId: 1, + * inputClusters: [OnOffCluster.ID], + * clusters: { onOff: { onOff: true } }, + * }], + * }); + * + * // The device never answers + * intercept((frame) => (frame.from === 'virtualDevice' ? null : undefined)); + * + * @param {Object} config + * @param {Array} config.endpoints - As the device describes them: `endpointId`, `inputClusters`, + * `outputClusters`, and `clusters` keyed by cluster name, each either attribute values or a + * `BoundCluster` instance that scripts the device's behavior. + * @param {number} [config.maxFrameSize] - Longest frame the zclNode may send, as the Homey stack. + * @returns {{ + * zclNode: Node, + * device: Node, + * frames: Array<{from: string, endpointId: number, clusterId: number, frame: Object, raw: Buffer, error?: Error}>, + * intercept: function(function(Object): (undefined|null|Buffer|Buffer[])): void, + * settle: function(): Promise, + * }} + * `zclNode` is the node under test, as a driver gets it in `onNodeInit`. `virtualDevice` sends commands + * through `virtualDevice.endpoints[n].clusters` and any other frame, such as an attribute report, with + * `virtualDevice.sendFrame(endpointId, clusterId, buffer)`. An interceptor sees each frame before + * delivery and returns `undefined` to pass it, `null` to drop it, or one or more buffers to + * deliver instead; throwing fails the sender's `sendFrame` like a stack error. `settle` resolves + * once every frame in flight, and the frames sent in reply, has been handled. + */ +function createVirtualDevice({ endpoints, maxFrameSize = HOMEY_MAX_ZCL_BYTES }) { + const frames = []; + const interceptors = []; + const inFlight = new Set(); + const nodes = {}; + + const link = (from, to) => ({ + endpointDescriptors: endpoints.map((ep) => ({ + endpointId: ep.endpointId, + inputClusters: ep.inputClusters || [], + outputClusters: ep.outputClusters || [], + })), + async sendFrame(endpointId, clusterId, data) { + if (from === 'zclNode' && data.length > maxFrameSize) { + throw new Error(`ZCL frame too long, got ${data.length} bytes, max: ${maxFrameSize}.`); + } + + let raws = [data]; + for (const interceptor of interceptors) { + raws = raws.flatMap((raw) => { + const result = interceptor({ + from, + endpointId, + clusterId, + frame: Endpoint.parseFrame(raw), + raw, + }); + if (result === undefined) return [raw]; + if (result === null) return []; + return result; + }); + } + + for (const raw of raws) { + const record = { from, endpointId, clusterId, raw }; + frames.push(record); + const delivery = (async () => { + // Parsed here, not up front: a malformed frame must reach the receiver as it is. + record.frame = Endpoint.parseFrame(raw); + await nodes[to].handleFrame(endpointId, clusterId, raw); + })() + .catch((err) => { + record.error = err; + }) + .finally(() => inFlight.delete(delivery)); + inFlight.add(delivery); + } + }, + }); + + nodes.zclNode = new Node(link('zclNode', 'virtualDevice')); + + // The device sends commands on its output clusters, so those get a Cluster on its side. + const virtualDeviceLink = link('virtualDevice', 'zclNode'); + virtualDeviceLink.endpointDescriptors = virtualDeviceLink.endpointDescriptors.map((ep) => ({ + ...ep, + inputClusters: ep.outputClusters, + })); + nodes.virtualDevice = new Node(virtualDeviceLink); + + endpoints.forEach((ep) => { + Object.entries(ep.clusters || {}).forEach(([clusterName, cluster]) => { + nodes.virtualDevice.endpoints[ep.endpointId].bind( + clusterName, + cluster instanceof BoundCluster ? cluster : createBoundClusterWithAttributes(cluster), + ); + }); + }); + + return { + zclNode: nodes.zclNode, + virtualDevice: nodes.virtualDevice, + frames, + intercept: (interceptor) => interceptors.push(interceptor), + async settle() { + while (inFlight.size) await Promise.all(inFlight); + }, + }; +} + +module.exports = { createVirtualDevice, HOMEY_MAX_ZCL_BYTES }; diff --git a/test/virtualDevice.js b/test/virtualDevice.js new file mode 100644 index 00000000..1c6d86f4 --- /dev/null +++ b/test/virtualDevice.js @@ -0,0 +1,180 @@ +'use strict'; + +const { describe, it, mock } = require('node:test'); +const assert = require('node:assert'); + +const BoundCluster = require('../lib/BoundCluster'); +const OnOffCluster = require('../lib/clusters/onOff'); +const MeteringCluster = require('../lib/clusters/metering'); +const { ZCLStandardHeader } = require('../lib/zclFrames'); +const { createVirtualDevice } = require('./util'); + +const ON_OFF = OnOffCluster.ATTRIBUTES.onOff.id; +const ON_TIME = OnOffCluster.ATTRIBUTES.onTime.id; + +/** A plug whose onOff cluster answers reads with what `answer(requestedIds)` returns. */ +function plugAnswering(answer) { + return createVirtualDevice({ + endpoints: [ + { + endpointId: 1, + inputClusters: [OnOffCluster.ID], + clusters: { + onOff: new (class extends BoundCluster { + async readAttributes({ attributes }) { + const buf = Buffer.alloc(255); + const len = OnOffCluster.attributeArrayStatusDataType.toBuffer( + buf, + answer(attributes), + 0, + ); + return { attributes: buf.slice(0, len) }; + } + })(), + }, + }, + ], + }); +} + +describe('Virtual device', function () { + it('answers a read and records both frames', async function () { + const { zclNode, frames } = createVirtualDevice({ + endpoints: [ + { endpointId: 1, inputClusters: [OnOffCluster.ID], clusters: { onOff: { onOff: true } } }, + ], + }); + + const result = await zclNode.endpoints[1].clusters.onOff.readAttributes(['onOff']); + + assert.deepStrictEqual(result, { onOff: true }); + assert.deepStrictEqual( + frames.map(({ from, frame }) => [from, frame.cmdId]), + [ + ['zclNode', 0x00], + ['virtualDevice', 0x01], + ], + ); + }); + + it('times out when an interceptor drops the response', async function (t) { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { zclNode, intercept, settle } = createVirtualDevice({ + endpoints: [ + { endpointId: 1, inputClusters: [OnOffCluster.ID], clusters: { onOff: { onOff: true } } }, + ], + }); + intercept((frame) => (frame.from === 'virtualDevice' ? null : undefined)); + + const read = zclNode.endpoints[1].clusters.onOff.readAttributes(['onOff']); + await settle(); + t.mock.timers.tick(10_000); + + await assert.rejects(read, /Timeout: Expected Response/); + }); + + it('rejects a frame longer than the Homey stack sends', async function () { + const { zclNode } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters: [OnOffCluster.ID] }], + maxFrameSize: 2, + }); + + await assert.rejects(zclNode.endpoints[1].clusters.onOff.toggle(), /ZCL frame too long/); + }); +}); + +describe('Virtual device: known bugs on master', function () { + it( + 'readAttributes stops when the device answers with unrequested ids', + { todo: 'fixed by #258' }, + async function () { + const { zclNode, intercept } = plugAnswering(() => [ + { id: ON_TIME, status: 'SUCCESS', value: 5 }, + ]); + let reads = 0; + intercept((frame) => { + // Guard: on master the read loops forever. + if (frame.from === 'zclNode' && ++reads > 3) throw new Error('read loop'); + }); + + const result = await zclNode.endpoints[1].clusters.onOff.readAttributes(['onOff']); + + assert.strictEqual(reads, 1); + assert.deepStrictEqual(result, {}); + }, + ); + + it( + 'readAttributes([]) on metering splits the request to fit a frame', + { todo: 'fixed by #259' }, + async function () { + const { zclNode } = createVirtualDevice({ + endpoints: [ + { + endpointId: 1, + inputClusters: [MeteringCluster.ID], + clusters: { + metering: { currentSummationDelivered: 1234, multiplier: 1, divisor: 1000 }, + }, + }, + ], + }); + + // Every request must also fit the Homey stack, which the virtual link enforces. + const { currentSummationDelivered, multiplier, divisor } = + await zclNode.endpoints[1].clusters.metering.readAttributes([]); + + assert.deepStrictEqual( + { currentSummationDelivered, multiplier, divisor }, + { currentSummationDelivered: 1234, multiplier: 1, divisor: 1000 }, + ); + }, + ); + + it( + 'Endpoint.unbind removes the binding, so the cluster is unsupported', + { todo: 'unbind leaves a Cluster in bindings' }, + async function () { + const { zclNode, virtualDevice } = createVirtualDevice({ + endpoints: [{ endpointId: 1, outputClusters: [OnOffCluster.ID] }], + }); + zclNode.endpoints[1].bind('onOff', new BoundCluster()); + zclNode.endpoints[1].unbind('onOff'); + + await assert.rejects( + virtualDevice.endpoints[1].clusters.onOff.toggle(), + /^Error: UNSUPPORTED_CLUSTER$/, + ); + }, + ); + + it( + 'a report with an unknown attribute id does not emit attr.undefined', + { todo: 'onReportAttributes emits attr.undefined' }, + async function () { + const { zclNode, virtualDevice, settle } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters: [OnOffCluster.ID] }], + }); + const onOff = zclNode.endpoints[1].clusters.onOff; + const onUndefined = mock.fn(); + const onOnOff = mock.fn(); + onOff.on('attr.undefined', onUndefined); + onOff.on('attr.onOff', onOnOff); + + const report = new ZCLStandardHeader(); + report.cmdId = 0x0a; // reportAttributes + report.frameControl.directionToClient = true; + report.frameControl.disableDefaultResponse = true; + // 0x4242 uint8 (0x20) 1, then onOff bool (0x10) true + report.data = Buffer.from([0x42, 0x42, 0x20, 0x01, ON_OFF, 0x00, 0x10, 0x01]); + await virtualDevice.sendFrame(1, OnOffCluster.ID, report.toBuffer()); + await settle(); + + assert.deepStrictEqual( + onOnOff.mock.calls.map((call) => call.arguments[0]), + [true], + ); + assert.strictEqual(onUndefined.mock.callCount(), 0); + }, + ); +}); From 67e84cc6e0f5453c74895e2c553929f76be247b7 Mon Sep 17 00:00:00 2001 From: Robin Bolscher Date: Tue, 27 Oct 2026 12:48:08 +0100 Subject: [PATCH 2/8] test: Move every test onto the virtual device harness The suite had four ways to fake a node: createMockNode, createConnectedNodePair, MOCK_DEVICES and hand-built Node stubs. All of them are now createVirtualDevice, and test/util/mockNode.js is gone. Migrating exposed tests that could not fail. They now can, each checked by breaking the library code it covers: - waitForResponse: false was passed as command args, so the test waited anyway. - The mixed-manufacturer tests accepted any error, including one from a binding left over from an earlier test. - Not answering a default response was never reached, because our own default responses set Disable Default Response; the device now sends one without it. - Assertions inside handlers became error responses or hung done callbacks; tests now assert after await or settle(), and on wire frames instead of spies. - The attribute 0x0000 test asserted nothing; it now decodes a noData report. Duplicates of the ZCLError and Default Response tests are removed, the four tests of MOCK_DEVICES itself are dropped, and OTA runs the way Homey uses it: the device requests, the node serves. The harness gains frame meta, eager parsing and a 1000-frame cap that turns a request/response loop into a failure. --- test/attributeReport.js | 35 ++- test/colorControl.js | 50 ++-- test/defaultResponse.js | 64 ++--- test/doorLock.js | 95 ++++--- test/iasZone.js | 137 ++++------ test/levelControl.js | 23 +- test/onOff.js | 30 +-- test/ota.js | 68 +++-- test/testClusterResponseTimeout.js | 375 ++++++++++------------------ test/testMfgSpecificCommands.js | 366 ++++++++++----------------- test/testNode.js | 384 ++++++++++------------------- test/testTrxSequenceNumber.js | 75 ++---- test/testZCLError.js | 334 ++++++------------------- test/util/index.js | 23 +- test/util/mockNode.js | 324 ------------------------ test/util/virtualDevice.js | 63 +++-- test/virtualDevice.js | 12 + 17 files changed, 758 insertions(+), 1700 deletions(-) delete mode 100644 test/util/mockNode.js diff --git a/test/attributeReport.js b/test/attributeReport.js index 79a86e77..fb16522f 100644 --- a/test/attributeReport.js +++ b/test/attributeReport.js @@ -1,28 +1,18 @@ 'use strict'; -const { describe, it } = require('node:test'); +const { describe, it, mock } = require('node:test'); const assert = require('node:assert'); const OnOffCluster = require('../lib/clusters/onOff'); -const { createMockNode } = require('./util'); +const { createVirtualDevice } = require('./util'); const { ZCLStandardHeader } = require('../lib/zclFrames'); describe('Attribute report', function () { - it('should emit the transaction sequence number with the attribute value', function (t, done) { - const node = createMockNode({ - endpoints: [ - { - endpointId: 1, - inputClusters: [OnOffCluster.ID], - }, - ], - }); - - node.endpoints[1].clusters.onOff.on('attr.onOff', (value, meta) => { - assert.strictEqual(value, true); - // Only whitelisted fields, not the stack's frame meta - assert.deepStrictEqual(meta, { transactionSequenceNumber: 42 }); - done(); + it('should emit the transaction sequence number with the attribute value', async function () { + const { zclNode, virtualDevice, settle } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters: [OnOffCluster.ID] }], }); + const onOnOff = mock.fn(); + zclNode.endpoints[1].clusters.onOff.on('attr.onOff', onOnOff); const frame = new ZCLStandardHeader(); frame.cmdId = 0x0a; // reportAttributes @@ -30,7 +20,16 @@ describe('Attribute report', function () { frame.frameControl.directionToClient = true; // attributeId 0x0000 (onOff), dataType 0x10 (bool), value true frame.data = Buffer.from([0x00, 0x00, 0x10, 0x01]); + await virtualDevice.sendFrame(1, OnOffCluster.ID, frame.toBuffer(), { + dstEndpoint: 1, + linkQuality: 200, + }); + await settle(); - node.handleFrame(1, OnOffCluster.ID, frame.toBuffer(), { dstEndpoint: 1, linkQuality: 200 }); + // Only whitelisted fields, not the stack's frame meta + assert.deepStrictEqual( + onOnOff.mock.calls.map((call) => call.arguments), + [[true, { transactionSequenceNumber: 42 }]], + ); }); }); diff --git a/test/colorControl.js b/test/colorControl.js index c84caa4c..f20b39c6 100644 --- a/test/colorControl.js +++ b/test/colorControl.js @@ -1,18 +1,16 @@ 'use strict'; -const { describe, it, afterEach, mock } = require('node:test'); +const { describe, it } = require('node:test'); const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); const ColorControlCluster = require('../lib/clusters/colorControl'); -const Endpoint = require('../lib/Endpoint'); -const { createMockNode } = require('./util'); +const { createVirtualDevice } = require('./util'); // Sends colorLoopSet on a fresh node and returns the parsed outgoing frame, so tests can assert // the payload bytes against the ZCL spec instead of round-tripping through our own definition async function sendColorLoopSet(args) { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice, frames } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -21,7 +19,7 @@ async function sendColorLoopSet(args) { ], }); - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'colorControl', new (class extends BoundCluster { async colorLoopSet() { @@ -30,20 +28,15 @@ async function sendColorLoopSet(args) { })(), ); - const sendFrameSpy = mock.method(node, 'sendFrame'); - await node.endpoints[1].clusters.colorControl.colorLoopSet(args); + await zclNode.endpoints[1].clusters.colorControl.colorLoopSet(args); - return Endpoint.parseFrame(sendFrameSpy.mock.calls[0].arguments[2]); + assert.strictEqual(frames[0].from, 'zclNode'); + return frames[0].frame; } describe('Color Control', function () { - afterEach(function () { - mock.restoreAll(); - }); - it('should receive moveToColor', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -53,7 +46,7 @@ describe('Color Control', function () { }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'colorControl', new (class extends BoundCluster { async moveToColor(data) { @@ -62,7 +55,7 @@ describe('Color Control', function () { })(), ); - await node.endpoints[1].clusters.colorControl.moveToColor({ + await zclNode.endpoints[1].clusters.colorControl.moveToColor({ colorX: 0x5000, colorY: 0x3000, transitionTime: 20, @@ -74,8 +67,7 @@ describe('Color Control', function () { }); it('should receive moveToColorTemperature', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -85,7 +77,7 @@ describe('Color Control', function () { }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'colorControl', new (class extends BoundCluster { async moveToColorTemperature(data) { @@ -94,7 +86,7 @@ describe('Color Control', function () { })(), ); - await node.endpoints[1].clusters.colorControl.moveToColorTemperature({ + await zclNode.endpoints[1].clusters.colorControl.moveToColorTemperature({ colorTemperature: 370, transitionTime: 15, }); @@ -104,8 +96,7 @@ describe('Color Control', function () { }); it('should receive moveToHueAndSaturation', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -115,7 +106,7 @@ describe('Color Control', function () { }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'colorControl', new (class extends BoundCluster { async moveToHueAndSaturation(data) { @@ -124,7 +115,7 @@ describe('Color Control', function () { })(), ); - await node.endpoints[1].clusters.colorControl.moveToHueAndSaturation({ + await zclNode.endpoints[1].clusters.colorControl.moveToHueAndSaturation({ hue: 180, saturation: 200, transitionTime: 10, @@ -136,8 +127,7 @@ describe('Color Control', function () { }); it('should receive colorLoopSet', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -147,7 +137,7 @@ describe('Color Control', function () { }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'colorControl', new (class extends BoundCluster { async colorLoopSet(data) { @@ -156,7 +146,7 @@ describe('Color Control', function () { })(), ); - await node.endpoints[1].clusters.colorControl.colorLoopSet({ + await zclNode.endpoints[1].clusters.colorControl.colorLoopSet({ updateFlags: ['updateAction', 'updateDirection', 'updateTime', 'updateStartHue'], action: 'activateFromColorLoopStartEnhancedHue', direction: 'incrementHue', @@ -176,7 +166,7 @@ describe('Color Control', function () { assert.strictEqual(receivedData.startHue, 0x2300); }); - // The loopback test above encodes and decodes with the same definition, so it passes even when + // The round-trip test above encodes and decodes with the same definition, so it passes even when // that definition is wrong on the wire. These bytes come from the spec, not from our types: // ZCL r8 Figure 5-17 (payload layout), Figure 5-18 (updateFlags bits), Table 5.25 (action // values), Table 5.26 (direction values), Table 5.13 (command id). diff --git a/test/defaultResponse.js b/test/defaultResponse.js index ea8474ee..3be94cdb 100644 --- a/test/defaultResponse.js +++ b/test/defaultResponse.js @@ -3,32 +3,33 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); -const { Endpoint, BoundCluster } = require('..'); +const { BoundCluster } = require('..'); const DoorLockCluster = require('../lib/clusters/doorLock'); +const { createVirtualDevice } = require('./util'); // Door Lock setWeekDaySchedule is cluster-specific command 0x0B, same id as global Default Response const SET_WEEK_DAY_SCHEDULE_REQUEST = Buffer.from([ 0x01, 0x05, 0x0b, 0x01, 0x02, 0x00, 0x02, 0x08, 0x00, 0x17, 0x00, ]); -function createEndpoint() { - const sentFrames = []; - const endpoint = new Endpoint( - { - sendFrame: async (_endpointId, _clusterId, frame) => { - sentFrames.push(frame.toString('hex')); - }, - }, - { endpointId: 1, inputClusters: [DoorLockCluster.ID], outputClusters: [] }, - ); - return { endpoint, sentFrames }; +/** Sends `request` to a lock served by `doorLock` and returns what the lock sent back, as hex. */ +async function lockAnswersTo(request, doorLock) { + const { zclNode, frames, settle } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters: [DoorLockCluster.ID], clusters: { doorLock } }], + }); + + await zclNode.sendFrame(1, DoorLockCluster.ID, request); + await settle(); + + return frames + .filter(({ from }) => from === 'virtualDevice') + .map(({ raw }) => raw.toString('hex')); } describe('Default Response', function () { it('sends the cluster-specific response to a cluster-specific command 0x0B', async function () { - const { endpoint, sentFrames } = createEndpoint(); - endpoint.bind( - 'doorLock', + const answers = await lockAnswersTo( + SET_WEEK_DAY_SCHEDULE_REQUEST, new (class extends BoundCluster { async setWeekDaySchedule() { return { status: 0 }; @@ -36,31 +37,34 @@ describe('Default Response', function () { })(), ); - await endpoint.handleFrame(DoorLockCluster.ID, SET_WEEK_DAY_SCHEDULE_REQUEST); - - assert.deepEqual(sentFrames, ['19050b00']); + assert.deepEqual(answers, ['19050b00']); }); it('sends an error Default Response to an unhandled cluster-specific command 0x0B', async function () { - const { endpoint, sentFrames } = createEndpoint(); - endpoint.bind('doorLock', new BoundCluster()); - - await endpoint.handleFrame(DoorLockCluster.ID, SET_WEEK_DAY_SCHEDULE_REQUEST); - // The error response is sent without awaiting, let it flush - await new Promise(setImmediate); + const answers = await lockAnswersTo(SET_WEEK_DAY_SCHEDULE_REQUEST, new BoundCluster()); // Default Response (0x0B) for command 0x0B with any error status; the exact status is not // what this test is about - assert.equal(sentFrames.length, 1); - assert.match(sentFrames[0], /^18050b0b(?!00)[0-9a-f]{2}$/); + assert.equal(answers.length, 1); + assert.match(answers[0], /^18050b0b(?!00)[0-9a-f]{2}$/); }); it('does not respond to a global Default Response', async function () { - const { endpoint, sentFrames } = createEndpoint(); + const { virtualDevice, frames, settle } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters: [DoorLockCluster.ID] }], + }); - await endpoint.handleFrame(DoorLockCluster.ID, Buffer.from([0x08, 0x05, 0x0b, 0x00, 0x00])); - await new Promise(setImmediate); + // A stray Default Response without Disable Default Response set, as some devices send + await virtualDevice.sendFrame( + 1, + DoorLockCluster.ID, + Buffer.from([0x08, 0x05, 0x0b, 0x00, 0x00]), + ); + await settle(); - assert.deepEqual(sentFrames, []); + assert.deepEqual( + frames.filter(({ from }) => from === 'zclNode'), + [], + ); }); }); diff --git a/test/doorLock.js b/test/doorLock.js index 842233c2..33db3d35 100644 --- a/test/doorLock.js +++ b/test/doorLock.js @@ -4,13 +4,12 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); const DoorLockCluster = require('../lib/clusters/doorLock'); -const { createMockNode } = require('./util'); +const { createVirtualDevice } = require('./util'); const { ZCLStandardHeader } = require('../lib/zclFrames'); describe('Door Lock', function () { it('should receive lockDoor', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -20,7 +19,7 @@ describe('Door Lock', function () { }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'doorLock', new (class extends BoundCluster { async lockDoor(data) { @@ -29,7 +28,7 @@ describe('Door Lock', function () { })(), ); - await node.endpoints[1].clusters.doorLock.lockDoor({ + await zclNode.endpoints[1].clusters.doorLock.lockDoor({ pinCode: Buffer.from([0x31, 0x32, 0x33, 0x34]), }); @@ -37,8 +36,7 @@ describe('Door Lock', function () { }); it('should receive lockDoor without arguments', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -47,26 +45,24 @@ describe('Door Lock', function () { ], }); - let called = false; - node.endpoints[1].bind( + let receivedData = null; + virtualDevice.endpoints[1].bind( 'doorLock', new (class extends BoundCluster { async lockDoor(data) { - called = true; - // pinCode should be empty buffer when not provided - assert.deepStrictEqual(data.pinCode, Buffer.from([])); + receivedData = data; } })(), ); // Call without any arguments - should not throw - await node.endpoints[1].clusters.doorLock.lockDoor(); - assert.strictEqual(called, true); + await zclNode.endpoints[1].clusters.doorLock.lockDoor(); + // pinCode should be empty buffer when not provided + assert.deepStrictEqual(receivedData.pinCode, Buffer.from([])); }); it('should receive unlockDoor', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -76,7 +72,7 @@ describe('Door Lock', function () { }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'doorLock', new (class extends BoundCluster { async unlockDoor(data) { @@ -85,7 +81,7 @@ describe('Door Lock', function () { })(), ); - await node.endpoints[1].clusters.doorLock.unlockDoor({ + await zclNode.endpoints[1].clusters.doorLock.unlockDoor({ pinCode: Buffer.from([0x30, 0x30, 0x30, 0x30]), }); @@ -93,8 +89,7 @@ describe('Door Lock', function () { }); it('should receive setPINCode', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -104,7 +99,7 @@ describe('Door Lock', function () { }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'doorLock', new (class extends BoundCluster { async setPINCode(data) { @@ -113,7 +108,7 @@ describe('Door Lock', function () { })(), ); - await node.endpoints[1].clusters.doorLock.setPINCode({ + await zclNode.endpoints[1].clusters.doorLock.setPINCode({ userId: 1, userStatus: 'occupiedEnabled', userType: 'unrestricted', @@ -128,9 +123,8 @@ describe('Door Lock', function () { // Server-to-client notifications require manual frame construction // since they are sent BY the device TO the controller - it('should receive operationEventNotification', function (t, done) { - const node = createMockNode({ - loopback: true, + it('should receive operationEventNotification', async function () { + const { zclNode, virtualDevice, settle } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -139,14 +133,9 @@ describe('Door Lock', function () { ], }); - node.endpoints[1].clusters.doorLock.onOperationEventNotification = (data) => { - assert.strictEqual(data.operationEventSource, 1); // Keypad - assert.strictEqual(data.operationEventCode, 2); // Unlock - assert.strictEqual(data.userId, 3); - assert.deepStrictEqual(data.pin, Buffer.from([0x31, 0x32, 0x33, 0x34])); - assert.strictEqual(data.zigBeeLocalTime, 0x12345678); - assert.deepStrictEqual(data.data, Buffer.from([])); - done(); + let data; + zclNode.endpoints[1].clusters.doorLock.onOperationEventNotification = (received) => { + data = received; }; const frame = new ZCLStandardHeader(); @@ -157,12 +146,19 @@ describe('Door Lock', function () { 0x01, 0x02, 0x03, 0x00, 0x04, 0x31, 0x32, 0x33, 0x34, 0x78, 0x56, 0x34, 0x12, 0x00, ]); - node.handleFrame(1, DoorLockCluster.ID, frame.toBuffer(), {}); + await virtualDevice.sendFrame(1, DoorLockCluster.ID, frame.toBuffer()); + await settle(); + + assert.strictEqual(data.operationEventSource, 1); // Keypad + assert.strictEqual(data.operationEventCode, 2); // Unlock + assert.strictEqual(data.userId, 3); + assert.deepStrictEqual(data.pin, Buffer.from([0x31, 0x32, 0x33, 0x34])); + assert.strictEqual(data.zigBeeLocalTime, 0x12345678); + assert.deepStrictEqual(data.data, Buffer.from([])); }); - it('should receive programmingEventNotification', function (t, done) { - const node = createMockNode({ - loopback: true, + it('should receive programmingEventNotification', async function () { + const { zclNode, virtualDevice, settle } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -171,16 +167,9 @@ describe('Door Lock', function () { ], }); - node.endpoints[1].clusters.doorLock.onProgrammingEventNotification = (data) => { - assert.strictEqual(data.programEventSource, 2); // RF - assert.strictEqual(data.programEventCode, 3); // PIN added - assert.strictEqual(data.userId, 7); - assert.deepStrictEqual(data.pin, Buffer.from([0x35, 0x36, 0x37, 0x38])); - assert.strictEqual(data.userType, 'unrestricted'); - assert.strictEqual(data.userStatus, 'occupiedEnabled'); - assert.strictEqual(data.zigBeeLocalTime, 0xaabbccdd); - assert.deepStrictEqual(data.data, Buffer.from([])); - done(); + let data; + zclNode.endpoints[1].clusters.doorLock.onProgrammingEventNotification = (received) => { + data = received; }; const frame = new ZCLStandardHeader(); @@ -192,6 +181,16 @@ describe('Door Lock', function () { 0x00, ]); - node.handleFrame(1, DoorLockCluster.ID, frame.toBuffer(), {}); + await virtualDevice.sendFrame(1, DoorLockCluster.ID, frame.toBuffer()); + await settle(); + + assert.strictEqual(data.programEventSource, 2); // RF + assert.strictEqual(data.programEventCode, 3); // PIN added + assert.strictEqual(data.userId, 7); + assert.deepStrictEqual(data.pin, Buffer.from([0x35, 0x36, 0x37, 0x38])); + assert.strictEqual(data.userType, 'unrestricted'); + assert.strictEqual(data.userStatus, 'occupiedEnabled'); + assert.strictEqual(data.zigBeeLocalTime, 0xaabbccdd); + assert.deepStrictEqual(data.data, Buffer.from([])); }); }); diff --git a/test/iasZone.js b/test/iasZone.js index 4fad1823..4b245d85 100644 --- a/test/iasZone.js +++ b/test/iasZone.js @@ -4,51 +4,50 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); const IASZoneCluster = require('../lib/clusters/iasZone'); -const { createMockNode, MOCK_DEVICES } = require('./util'); +const { createVirtualDevice } = require('./util'); const { ZCLStandardHeader } = require('../lib/zclFrames'); -describe('IAS Zone', function () { - // Server-to-client notification - requires manual frame - it('should receive onZoneEnrollRequest', function (t, done) { - const node = createMockNode({ - loopback: true, - endpoints: [ - { - endpointId: 1, - inputClusters: [IASZoneCluster.ID], - }, - ], - }); +/** Has an IAS zone sensor send the cluster-specific `cmdId` with `data` to the node under test. */ +async function receiveFromSensor(cmdId, data, handlerName) { + const { zclNode, virtualDevice, settle } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters: [IASZoneCluster.ID] }], + }); + + let received; + zclNode.endpoints[1].clusters.iasZone[handlerName] = (payload) => { + received = payload; + }; + + const frame = new ZCLStandardHeader(); + frame.cmdId = cmdId; + frame.frameControl.directionToClient = true; + frame.frameControl.clusterSpecific = true; + frame.data = data; + await virtualDevice.sendFrame(1, IASZoneCluster.ID, frame.toBuffer()); + await settle(); - node.endpoints[1].clusters.iasZone.onZoneEnrollRequest = (data) => { - assert.strictEqual(data.zoneType, 'keypad'); - assert.strictEqual(data.manufacturerCode, 4117); - done(); - }; + return received; +} - const frame = new ZCLStandardHeader(); - frame.cmdId = IASZoneCluster.COMMANDS.zoneEnrollRequest.id; - frame.frameControl.directionToClient = true; - frame.frameControl.clusterSpecific = true; - frame.data = Buffer.from([0x1d, 0x02, 0x15, 0x10]); +describe('IAS Zone', function () { + it('should receive onZoneEnrollRequest', async function () { + const data = await receiveFromSensor( + IASZoneCluster.COMMANDS.zoneEnrollRequest.id, + Buffer.from([0x1d, 0x02, 0x15, 0x10]), + 'onZoneEnrollRequest', + ); - node.handleFrame(1, IASZoneCluster.ID, frame.toBuffer(), {}); + assert.strictEqual(data.zoneType, 'keypad'); + assert.strictEqual(data.manufacturerCode, 4117); }); - // Client-to-server command - can use loopback it('should send zoneEnrollResponse', async function () { - const node = createMockNode({ - loopback: true, - endpoints: [ - { - endpointId: 1, - inputClusters: [IASZoneCluster.ID], - }, - ], + const { zclNode, virtualDevice } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters: [IASZoneCluster.ID] }], }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'iasZone', new (class extends BoundCluster { async zoneEnrollResponse(data) { @@ -57,7 +56,7 @@ describe('IAS Zone', function () { })(), ); - await node.endpoints[1].clusters.iasZone.zoneEnrollResponse({ + await zclNode.endpoints[1].clusters.iasZone.zoneEnrollResponse({ enrollResponseCode: 'success', zoneId: 10, }); @@ -66,64 +65,16 @@ describe('IAS Zone', function () { assert.strictEqual(receivedData.zoneId, 10); }); - // Server-to-client notification - requires manual frame - it('should receive zoneStatusChangeNotification', function (t, done) { - const node = createMockNode({ - loopback: true, - endpoints: [ - { - endpointId: 1, - inputClusters: [IASZoneCluster.ID], - }, - ], - }); - - node.endpoints[1].clusters.iasZone.onZoneStatusChangeNotification = (data) => { - assert.strictEqual(data.zoneStatus.supervisionReports, true); - assert.strictEqual(data.extendedStatus, 0); - assert.strictEqual(data.zoneId, 10); - assert.strictEqual(data.delay, 108); - done(); - }; - - const frame = new ZCLStandardHeader(); - frame.cmdId = IASZoneCluster.COMMANDS.zoneStatusChangeNotification.id; - frame.frameControl.directionToClient = true; - frame.frameControl.clusterSpecific = true; - frame.data = Buffer.from([0x10, 0x00, 0x00, 0x0a, 0x6c, 0x00]); - - node.handleFrame(1, IASZoneCluster.ID, frame.toBuffer(), {}); - }); - - describe('Mock Device Factory', function () { - it('should create a motion sensor with correct zone type', function () { - const sensor = MOCK_DEVICES.motionSensor(); - const boundCluster = sensor.endpoints[1].bindings.iasZone; - assert.strictEqual(boundCluster.zoneType, 0x000d, 'Should be motion sensor type'); - assert.strictEqual(boundCluster.zoneState, 1, 'Should be enrolled'); - }); - - it('should create a contact sensor with correct zone type', function () { - const sensor = MOCK_DEVICES.contactSensor(); - const boundCluster = sensor.endpoints[1].bindings.iasZone; - assert.strictEqual(boundCluster.zoneType, 0x0015, 'Should be contact switch type'); - assert.strictEqual(boundCluster.zoneState, 1, 'Should be enrolled'); - }); - - it('should allow attribute overrides', function () { - const sensor = MOCK_DEVICES.motionSensor({ - iasZone: { zoneStatus: 0x0001 }, // Alarm1 active - }); - const boundCluster = sensor.endpoints[1].bindings.iasZone; - assert.strictEqual(boundCluster.zoneStatus, 0x0001, 'Should have alarm1 bit set'); - }); + it('should receive zoneStatusChangeNotification', async function () { + const data = await receiveFromSensor( + IASZoneCluster.COMMANDS.zoneStatusChangeNotification.id, + Buffer.from([0x10, 0x00, 0x00, 0x0a, 0x6c, 0x00]), + 'onZoneStatusChangeNotification', + ); - it('should create temp/humidity sensor with measurement values', function () { - const sensor = MOCK_DEVICES.tempHumiditySensor(); - const tempCluster = sensor.endpoints[1].bindings.temperatureMeasurement; - const humCluster = sensor.endpoints[1].bindings.relativeHumidity; - assert.strictEqual(tempCluster.measuredValue, 2150, 'Should be 21.50°C raw'); - assert.strictEqual(humCluster.measuredValue, 6500, 'Should be 65.00% raw'); - }); + assert.strictEqual(data.zoneStatus.supervisionReports, true); + assert.strictEqual(data.extendedStatus, 0); + assert.strictEqual(data.zoneId, 10); + assert.strictEqual(data.delay, 108); }); }); diff --git a/test/levelControl.js b/test/levelControl.js index a1f6aded..cc1a29cd 100644 --- a/test/levelControl.js +++ b/test/levelControl.js @@ -4,12 +4,11 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); const LevelControlCluster = require('../lib/clusters/levelControl'); -const { createMockNode } = require('./util'); +const { createVirtualDevice } = require('./util'); describe('Level Control', function () { it('should receive moveToLevel', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -19,7 +18,7 @@ describe('Level Control', function () { }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'levelControl', new (class extends BoundCluster { async moveToLevel(data) { @@ -28,7 +27,7 @@ describe('Level Control', function () { })(), ); - await node.endpoints[1].clusters.levelControl.moveToLevel({ + await zclNode.endpoints[1].clusters.levelControl.moveToLevel({ level: 128, transitionTime: 10, }); @@ -38,8 +37,7 @@ describe('Level Control', function () { }); it('should receive step', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -49,7 +47,7 @@ describe('Level Control', function () { }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'levelControl', new (class extends BoundCluster { async step(data) { @@ -58,7 +56,7 @@ describe('Level Control', function () { })(), ); - await node.endpoints[1].clusters.levelControl.step({ + await zclNode.endpoints[1].clusters.levelControl.step({ mode: 'up', stepSize: 50, transitionTime: 5, @@ -70,8 +68,7 @@ describe('Level Control', function () { }); it('should receive stop', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -81,7 +78,7 @@ describe('Level Control', function () { }); let called = false; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'levelControl', new (class extends BoundCluster { async stop() { @@ -90,7 +87,7 @@ describe('Level Control', function () { })(), ); - await node.endpoints[1].clusters.levelControl.stop(); + await zclNode.endpoints[1].clusters.levelControl.stop(); assert.strictEqual(called, true); }); }); diff --git a/test/onOff.js b/test/onOff.js index ba8bd77d..a2284b18 100644 --- a/test/onOff.js +++ b/test/onOff.js @@ -4,12 +4,11 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); const OnOffCluster = require('../lib/clusters/onOff'); -const { createMockNode } = require('./util'); +const { createVirtualDevice } = require('./util'); describe('On/Off', function () { it('should receive setOn', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -19,7 +18,7 @@ describe('On/Off', function () { }); let called = false; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'onOff', new (class extends BoundCluster { async setOn() { @@ -28,13 +27,12 @@ describe('On/Off', function () { })(), ); - await node.endpoints[1].clusters.onOff.setOn(); + await zclNode.endpoints[1].clusters.onOff.setOn(); assert.strictEqual(called, true); }); it('should receive setOff', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -44,7 +42,7 @@ describe('On/Off', function () { }); let called = false; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'onOff', new (class extends BoundCluster { async setOff() { @@ -53,13 +51,12 @@ describe('On/Off', function () { })(), ); - await node.endpoints[1].clusters.onOff.setOff(); + await zclNode.endpoints[1].clusters.onOff.setOff(); assert.strictEqual(called, true); }); it('should receive toggle', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -69,7 +66,7 @@ describe('On/Off', function () { }); let called = false; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'onOff', new (class extends BoundCluster { async toggle() { @@ -78,13 +75,12 @@ describe('On/Off', function () { })(), ); - await node.endpoints[1].clusters.onOff.toggle(); + await zclNode.endpoints[1].clusters.onOff.toggle(); assert.strictEqual(called, true); }); it('should receive onWithTimedOff', async function () { - const node = createMockNode({ - loopback: true, + const { zclNode, virtualDevice } = createVirtualDevice({ endpoints: [ { endpointId: 1, @@ -94,7 +90,7 @@ describe('On/Off', function () { }); let receivedData = null; - node.endpoints[1].bind( + virtualDevice.endpoints[1].bind( 'onOff', new (class extends BoundCluster { async onWithTimedOff(data) { @@ -103,7 +99,7 @@ describe('On/Off', function () { })(), ); - await node.endpoints[1].clusters.onOff.onWithTimedOff({ + await zclNode.endpoints[1].clusters.onOff.onWithTimedOff({ onOffControl: 0x01, onTime: 100, offWaitTime: 50, diff --git a/test/ota.js b/test/ota.js index cb96e97a..61291db2 100644 --- a/test/ota.js +++ b/test/ota.js @@ -3,8 +3,8 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); -const Node = require('../lib/Node'); const OTACluster = require('../lib/clusters/ota'); +const { createVirtualDevice } = require('./util'); // ZCL Standard Header: frameControl(1), trxSequenceNumber(1), cmdId(1), data(rest) const ZCL_HEADER_SIZE = 3; @@ -14,39 +14,27 @@ const CMD_QUERY_NEXT_IMAGE_REQUEST = OTACluster.COMMANDS.queryNextImageRequest.i const CMD_QUERY_NEXT_IMAGE_RESPONSE = OTACluster.COMMANDS.queryNextImageRequest.response.id; /** - * Creates a mock node that captures raw ZCL frame buffers from every sendFrame call, - * with loopback so frames are handled by the same node. + * A device with an OTA client, as real devices have, and Homey's node serving OTA. The device asks + * for images; the node under test answers. */ -function createCapturingNode() { - const capturedFrames = []; - let node; - - const mockNode = { - sendFrame: (endpointId, clusterId, buffer) => { - capturedFrames.push(buffer); - return node.handleFrame(endpointId, clusterId, buffer, {}); - }, - endpointDescriptors: [ - { - endpointId: 1, - inputClusters: [OTACluster.ID], - outputClusters: [], - }, - ], - }; - - node = new Node(mockNode); - return { node, capturedFrames }; +function createOtaDevice() { + return createVirtualDevice({ endpoints: [{ endpointId: 1, outputClusters: [OTACluster.ID] }] }); +} + +/** The raw frame `from` sent with command `cmdId`. */ +function sentFrame(frames, from, cmdId) { + const sent = frames.find((record) => record.from === from && record.frame.cmdId === cmdId); + return sent && sent.raw; } describe('OTA', function () { // --- queryNextImageRequest: encodeMissingFieldsBehavior on the request --- it('should NOT encode hardwareVersion when omitted from queryNextImageRequest', async function () { - const { node, capturedFrames } = createCapturingNode(); + const { zclNode, virtualDevice, frames } = createOtaDevice(); let receivedData = null; - node.endpoints[1].bind( + zclNode.endpoints[1].bind( 'ota', new (class extends BoundCluster { async queryNextImageRequest(data) { @@ -56,7 +44,7 @@ describe('OTA', function () { })(), ); - await node.endpoints[1].clusters.ota.queryNextImageRequest({ + await virtualDevice.endpoints[1].clusters.ota.queryNextImageRequest({ fieldControl: 0, manufacturerCode: 0x1234, imageType: 0x0001, @@ -64,7 +52,7 @@ describe('OTA', function () { // hardwareVersion intentionally omitted }); - const requestFrame = capturedFrames.find((buf) => buf[2] === CMD_QUERY_NEXT_IMAGE_REQUEST); + const requestFrame = sentFrame(frames, 'virtualDevice', CMD_QUERY_NEXT_IMAGE_REQUEST); assert.ok(requestFrame, 'request frame not captured'); // fieldControl(1) + manufacturerCode(2) + imageType(2) + fileVersion(4) = 9 bytes @@ -84,9 +72,9 @@ describe('OTA', function () { }); it('should encode hardwareVersion when provided in queryNextImageRequest', async function () { - const { node, capturedFrames } = createCapturingNode(); + const { zclNode, virtualDevice, frames } = createOtaDevice(); - node.endpoints[1].bind( + zclNode.endpoints[1].bind( 'ota', new (class extends BoundCluster { async queryNextImageRequest() { @@ -95,7 +83,7 @@ describe('OTA', function () { })(), ); - await node.endpoints[1].clusters.ota.queryNextImageRequest({ + await virtualDevice.endpoints[1].clusters.ota.queryNextImageRequest({ fieldControl: ['hardwareVersionPresent'], manufacturerCode: 0x1234, imageType: 0x0001, @@ -103,7 +91,7 @@ describe('OTA', function () { hardwareVersion: 0x0002, }); - const requestFrame = capturedFrames.find((buf) => buf[2] === CMD_QUERY_NEXT_IMAGE_REQUEST); + const requestFrame = sentFrame(frames, 'virtualDevice', CMD_QUERY_NEXT_IMAGE_REQUEST); assert.ok(requestFrame, 'request frame not captured'); // fieldControl(1) + manufacturerCode(2) + imageType(2) + fileVersion(4) @@ -122,9 +110,9 @@ describe('OTA', function () { // --- queryNextImageResponse: encodeMissingFieldsBehavior on the response --- it('should encode all fields in queryNextImageResponse when status is SUCCESS', async function () { - const { node, capturedFrames } = createCapturingNode(); + const { zclNode, virtualDevice, frames } = createOtaDevice(); - node.endpoints[1].bind( + zclNode.endpoints[1].bind( 'ota', new (class extends BoundCluster { async queryNextImageRequest() { @@ -139,14 +127,14 @@ describe('OTA', function () { })(), ); - const response = await node.endpoints[1].clusters.ota.queryNextImageRequest({ + const response = await virtualDevice.endpoints[1].clusters.ota.queryNextImageRequest({ fieldControl: 0, manufacturerCode: 0x1234, imageType: 0x0001, fileVersion: 0x00000010, }); - const responseFrame = capturedFrames.find((buf) => buf[2] === CMD_QUERY_NEXT_IMAGE_RESPONSE); + const responseFrame = sentFrame(frames, 'zclNode', CMD_QUERY_NEXT_IMAGE_RESPONSE); assert.ok(responseFrame, 'response frame not captured'); // status(1) + manufacturerCode(2) + imageType(2) + fileVersion(4) + imageSize(4) = 13 bytes @@ -165,9 +153,9 @@ describe('OTA', function () { }); it('should encode only the status byte in queryNextImageResponse when status is NO_IMAGE_AVAILABLE', async function () { - const { node, capturedFrames } = createCapturingNode(); + const { zclNode, virtualDevice, frames } = createOtaDevice(); - node.endpoints[1].bind( + zclNode.endpoints[1].bind( 'ota', new (class extends BoundCluster { async queryNextImageRequest() { @@ -176,17 +164,17 @@ describe('OTA', function () { })(), ); - const response = await node.endpoints[1].clusters.ota.queryNextImageRequest({ + const response = await virtualDevice.endpoints[1].clusters.ota.queryNextImageRequest({ fieldControl: 0, manufacturerCode: 0x1234, imageType: 0x0001, fileVersion: 0x00000010, }); - const responseFrame = capturedFrames.find((buf) => buf[2] === CMD_QUERY_NEXT_IMAGE_RESPONSE); + const responseFrame = sentFrame(frames, 'zclNode', CMD_QUERY_NEXT_IMAGE_RESPONSE); assert.ok(responseFrame, 'response frame not captured'); - // Only status(1) — all other fields must be absent + // Only status(1): all other fields must be absent const payload = responseFrame.slice(ZCL_HEADER_SIZE); assert.strictEqual( payload.length, diff --git a/test/testClusterResponseTimeout.js b/test/testClusterResponseTimeout.js index 5d3ccd3a..d941164c 100644 --- a/test/testClusterResponseTimeout.js +++ b/test/testClusterResponseTimeout.js @@ -1,85 +1,65 @@ 'use strict'; -const { describe, it, beforeEach, afterEach, mock } = require('node:test'); +const { describe, it } = require('node:test'); const assert = require('node:assert'); const Cluster = require('../lib/Cluster'); -const Node = require('../lib/Node'); const { ZCLDataTypes } = require('../lib/zclTypes'); const BoundCluster = require('../lib/BoundCluster'); const OnOffCluster = require('../lib/clusters/onOff'); const BasicCluster = require('../lib/clusters/basic'); const AlarmsCluster = require('../lib/clusters/alarms'); const IasZoneCluster = require('../lib/clusters/iasZone'); +const { createVirtualDevice } = require('./util'); -describe('Cluster Response Timeout', function () { - let receivingNode; - let sendingNode; - - beforeEach(function () { - sendingNode = new Node({ - // Forward frames to receiving node - sendFrame: (...args) => { - receivingNode.handleFrame(...args); - }, - endpointDescriptors: [ - { - endpointId: 1, - inputClusters: [OnOffCluster.ID], - }, - ], - }); - - receivingNode = new Node({ - // Forward frames to sending node - sendFrame: (...args) => { - sendingNode.handleFrame(...args); - }, - endpointDescriptors: [ - { - endpointId: 1, - inputClusters: [OnOffCluster.ID], - }, - ], - }); - }); - - afterEach(function () { - mock.restoreAll(); - mock.timers.reset(); +/** A device whose `clusterName` cluster is served by `bound`. */ +function deviceServing(clusterId, clusterName, bound) { + return createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters: [clusterId], clusters: { [clusterName]: bound } }], }); +} + +const togglingDevice = () => + deviceServing( + OnOffCluster.ID, + 'onOff', + new (class extends BoundCluster { + toggle() {} + })(), + ); + +/** Makes the device send `colliding(tsn)` with the request's TSN right before each reply. */ +function collideBeforeReplies(intercept, colliding) { + intercept(({ from, frame, raw }) => + from === 'virtualDevice' ? [colliding(frame.trxSequenceNumber), raw] : undefined, + ); +} - it('should only start timeout after frame is sent', async function () { - mock.timers.enable({ apis: ['setTimeout'] }); - const originalSendFrame = sendingNode.endpoints[1].sendFrame.bind(sendingNode.endpoints[1]); - mock.method(sendingNode.endpoints[1], 'sendFrame', async (...args) => { - // Delay sendFrame by 150ms (longer than timeout) +describe('Cluster Response Timeout', function () { + it('should only start timeout after frame is sent', async function (t) { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { zclNode } = togglingDevice(); + const endpoint = zclNode.endpoints[1]; + const originalSendFrame = endpoint.sendFrame.bind(endpoint); + // Delay sendFrame by 150ms (longer than timeout) + t.mock.method(endpoint, 'sendFrame', async (...args) => { await new Promise((resolve) => setTimeout(resolve, 150)); return originalSendFrame(...args); }); - receivingNode.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - toggle() { - // Respond immediately - } - })(), - ); - // Start the command with 100ms timeout, if timeout starts immediately it will fire before // sendFrame completes and cause the command to fail - const toggled = sendingNode.endpoints[1].clusters.onOff.toggle(undefined, { timeout: 100 }); + const toggled = endpoint.clusters.onOff.toggle(undefined, { timeout: 100 }); // Let the stubbed sendFrame reach its fake timer before advancing past the 100ms timeout. await Promise.resolve(); - mock.timers.tick(150); + t.mock.timers.tick(150); await toggled; }); - it('should clear timeout when response is received', async function () { - const clearTimeoutSpy = mock.method(global, 'clearTimeout'); - - receivingNode.endpoints[1].bind( + it('should clear timeout when response is received', async function (t) { + const clearTimeoutSpy = t.mock.method(global, 'clearTimeout'); + const { zclNode } = deviceServing( + OnOffCluster.ID, 'onOff', new (class extends BoundCluster { async toggle() { @@ -89,7 +69,7 @@ describe('Cluster Response Timeout', function () { })(), ); - await sendingNode.endpoints[1].clusters.onOff.toggle(undefined, { timeout: 5000 }); + await zclNode.endpoints[1].clusters.onOff.toggle(undefined, { timeout: 5000 }); // Should have called clearTimeout to prevent timeout firing assert( @@ -99,62 +79,36 @@ describe('Cluster Response Timeout', function () { }); it('should not wait for response when waitForResponse is false', async function () { - const sendingNodeSendFrameSpy = mock.method(sendingNode, 'sendFrame'); + const { zclNode, frames, intercept } = togglingDevice(); + // A device that never answers: waiting would time out + intercept(({ from }) => (from === 'virtualDevice' ? null : undefined)); - receivingNode.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - toggle() { - // Respond with default response - } - })(), - ); - - // Send command without waiting for response - const result = await sendingNode.endpoints[1].clusters.onOff.toggle({ waitForResponse: false }); + const result = await zclNode.endpoints[1].clusters.onOff.toggle(undefined, { + waitForResponse: false, + }); - // Should return undefined since we're not waiting assert.strictEqual(result, undefined); - - // Only one frame should be sent (the command itself) - assert.strictEqual(sendingNodeSendFrameSpy.mock.callCount(), 1); + assert.strictEqual(frames.length, 1); }); it('should not resolve with a device-initiated frame that reuses the pending TSN', async function () { - const reportedValues = []; - sendingNode.endpoints[1].clusters.onOff.on('attr.onOff', (value) => reportedValues.push(value)); - - receivingNode.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - async toggle(args, meta, frame) { - // Device sends an attribute report numbered from its own counter, colliding with the TSN - await sendingNode.handleFrame( - 1, - OnOffCluster.ID, - Buffer.from([0x18, frame.trxSequenceNumber, 0x0a, 0x00, 0x00, 0x10, 0x01]), - ); - } - })(), + const { zclNode, intercept } = togglingDevice(); + // Device sends an attribute report numbered from its own counter, colliding with the TSN + collideBeforeReplies(intercept, (tsn) => + Buffer.from([0x18, tsn, 0x0a, 0x00, 0x00, 0x10, 0x01]), ); + const reportedValues = []; + zclNode.endpoints[1].clusters.onOff.on('attr.onOff', (value) => reportedValues.push(value)); - const result = await sendingNode.endpoints[1].clusters.onOff.toggle(); + const result = await zclNode.endpoints[1].clusters.onOff.toggle(); assert.deepStrictEqual(reportedValues, [true]); assert.strictEqual(result, undefined); }); it('should resolve getAlarm with a Get Alarm Response alone', async function () { - const pair = [AlarmsCluster.ID]; - const sender = new Node({ - sendFrame: (...args) => receiver.handleFrame(...args), - endpointDescriptors: [{ endpointId: 1, inputClusters: pair }], - }); - const receiver = new Node({ - sendFrame: (...args) => sender.handleFrame(...args), - endpointDescriptors: [{ endpointId: 1, inputClusters: pair }], - }); - receiver.endpoints[1].bind( + const { zclNode } = deviceServing( + AlarmsCluster.ID, 'alarms', new (class extends BoundCluster { getAlarm() { @@ -168,14 +122,15 @@ describe('Cluster Response Timeout', function () { })(), ); - const { alarms } = sender.endpoints[1].clusters; - const response = await alarms.getAlarm(undefined, { timeout: 1000 }); + const response = await zclNode.endpoints[1].clusters.alarms.getAlarm(undefined, { + timeout: 1000, + }); assert.strictEqual(response.alarmCode, 2); assert.strictEqual(response.timeStamp, 1234); }); - it('should resolve a command without declared response on a cluster-specific reply', async function () { + it('should resolve a command without declared response on a cluster-specific reply', async function (t) { class TuyaLikeCluster extends Cluster { static get ID() { return 0xef00; @@ -197,44 +152,32 @@ describe('Cluster Response Timeout', function () { } } Cluster.addCluster(TuyaLikeCluster); + t.after(() => Cluster.removeCluster(TuyaLikeCluster.ID)); - try { - const pair = [TuyaLikeCluster.ID]; - const sender = new Node({ - sendFrame: (...args) => receiver.handleFrame(...args), - endpointDescriptors: [{ endpointId: 1, inputClusters: pair }], - }); - const receiver = new Node({ - sendFrame: (...args) => sender.handleFrame(...args), - endpointDescriptors: [{ endpointId: 1, inputClusters: pair }], - }); - receiver.endpoints[1].bind( - 'tuyaLike', - new (class extends BoundCluster { - // Reply like a Tuya MCU: its own command with the request's TSN, no Default Response - async datapoint({ value }, meta, frame) { - await sender.handleFrame( - 1, - TuyaLikeCluster.ID, - Buffer.from([0x19, frame.trxSequenceNumber, 0x01, value]), - ); - } - })(), - ); + const { zclNode, virtualDevice } = deviceServing( + TuyaLikeCluster.ID, + 'tuyaLike', + new (class extends BoundCluster { + // Reply like a Tuya MCU: its own command with the request's TSN, no Default Response + async datapoint({ value }, meta, frame) { + await virtualDevice.sendFrame( + 1, + TuyaLikeCluster.ID, + Buffer.from([0x19, frame.trxSequenceNumber, 0x01, value]), + ); + } + })(), + ); - const { tuyaLike } = sender.endpoints[1].clusters; - const response = await tuyaLike.datapoint( - { value: 7 }, - { disableDefaultResponse: true, timeout: 1000 }, - ); + const response = await zclNode.endpoints[1].clusters.tuyaLike.datapoint( + { value: 7 }, + { disableDefaultResponse: true, timeout: 1000 }, + ); - assert.strictEqual(response.value, 7); - } finally { - Cluster.removeCluster(TuyaLikeCluster.ID); - } + assert.strictEqual(response.value, 7); }); - it('should treat a declared response without id as the command id', async function () { + it('should treat a declared response without id as the command id', async function (t) { class EchoCluster extends Cluster { static get ID() { return 0xfc01; @@ -260,66 +203,29 @@ describe('Cluster Response Timeout', function () { } } Cluster.addCluster(EchoCluster); + t.after(() => Cluster.removeCluster(EchoCluster.ID)); - try { - const pair = [EchoCluster.ID]; - const sender = new Node({ - sendFrame: (...args) => receiver.handleFrame(...args), - endpointDescriptors: [{ endpointId: 1, inputClusters: pair }], - }); - const receiver = new Node({ - sendFrame: (...args) => sender.handleFrame(...args), - endpointDescriptors: [{ endpointId: 1, inputClusters: pair }], - }); - receiver.endpoints[1].bind( - 'echo', - new (class extends BoundCluster { - // An unrelated device command reuses the TSN first, then the real response (id 0x05) - async ping({ value }, meta, frame) { - await sender.handleFrame( - 1, - EchoCluster.ID, - Buffer.from([0x19, frame.trxSequenceNumber, 0x06, 99]), - ); - return { value }; - } - })(), - ); + const { zclNode, intercept } = deviceServing( + EchoCluster.ID, + 'echo', + new (class extends BoundCluster { + async ping({ value }) { + return { value }; + } + })(), + ); + // An unrelated device command reuses the TSN first, then the real response (id 0x05) + collideBeforeReplies(intercept, (tsn) => Buffer.from([0x19, tsn, 0x06, 99])); - const { echo } = sender.endpoints[1].clusters; - const response = await echo.ping({ value: 7 }, { timeout: 1000 }); + const response = await zclNode.endpoints[1].clusters.echo.ping({ value: 7 }, { timeout: 1000 }); - assert.strictEqual(response.value, 7); - } finally { - Cluster.removeCluster(EchoCluster.ID); - } + assert.strictEqual(response.value, 7); }); describe('pending response matching', function () { - // Node pair where the receiving side first sends `colliding(tsn)` with the request's TSN, - // then its real reply - function createCollidingPair(clusterId, colliding) { - const descriptors = [{ endpointId: 1, inputClusters: [clusterId] }]; - const sender = new Node({ - sendFrame: (...args) => receiver.handleFrame(...args), - endpointDescriptors: descriptors, - }); - const receiver = new Node({ - sendFrame: async (endpointId, frameClusterId, frame) => { - await sender.handleFrame(endpointId, frameClusterId, colliding(frame[1])); - await sender.handleFrame(endpointId, frameClusterId, frame); - }, - endpointDescriptors: descriptors, - }); - return { sender, receiver }; - } - it('should not resolve a global command on a cluster-specific frame with its response id', async function () { - // IAS Zone Enroll Request is cluster-specific 0x01, the same id as Read Attributes Response - const { sender, receiver } = createCollidingPair(IasZoneCluster.ID, (tsn) => - Buffer.from([0x19, tsn, 0x01, 0x0d, 0x00, 0x00, 0x00]), - ); - receiver.endpoints[1].bind( + const { zclNode, intercept } = deviceServing( + IasZoneCluster.ID, 'iasZone', new (class extends BoundCluster { get zoneState() { @@ -327,88 +233,73 @@ describe('Cluster Response Timeout', function () { } })(), ); + // IAS Zone Enroll Request is cluster-specific 0x01, the same id as Read Attributes Response + collideBeforeReplies(intercept, (tsn) => + Buffer.from([0x19, tsn, 0x01, 0x0d, 0x00, 0x00, 0x00]), + ); - const { iasZone } = sender.endpoints[1].clusters; - const result = await iasZone.readAttributes(['zoneState'], { timeout: 1000 }); + const result = await zclNode.endpoints[1].clusters.iasZone.readAttributes(['zoneState'], { + timeout: 1000, + }); assert.deepStrictEqual(result, { zoneState: 'enrolled' }); }); it('should not resolve on a Default Response for another command', async function () { + const { zclNode, intercept } = togglingDevice(); // Default Response with FAILURE for cmdId 0x01 (on), while toggle (0x02) is pending - const { sender, receiver } = createCollidingPair(OnOffCluster.ID, (tsn) => - Buffer.from([0x18, tsn, 0x0b, 0x01, 0x01]), - ); - receiver.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - toggle() {} - })(), - ); + collideBeforeReplies(intercept, (tsn) => Buffer.from([0x18, tsn, 0x0b, 0x01, 0x01])); - await sender.endpoints[1].clusters.onOff.toggle(undefined, { timeout: 1000 }); + await zclNode.endpoints[1].clusters.onOff.toggle(undefined, { timeout: 1000 }); }); }); it('should resolve when the response arrives before sendFrame rejects', async function () { - receivingNode.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - toggle() {} - })(), - ); - sendingNode.sendFrame = async (endpointId, clusterId, frame) => { - await receivingNode.handleFrame(endpointId, clusterId, frame); + const { zclNode, settle } = togglingDevice(); + // The stack reports a failed send (e.g. no APS ACK in time) after the device already answered + const sendFrame = zclNode.sendFrame; + zclNode.sendFrame = async (...args) => { + await sendFrame(...args); + await settle(); throw new Error('device not responding'); }; - await sendingNode.endpoints[1].clusters.onOff.toggle(); + await zclNode.endpoints[1].clusters.onOff.toggle(); }); it('should reject when sendFrame rejects without a response', async function () { - sendingNode.sendFrame = async () => { + const { zclNode, intercept } = togglingDevice(); + intercept(() => { throw new Error('device not responding'); - }; + }); - await assert.rejects(sendingNode.endpoints[1].clusters.onOff.toggle(), /device not responding/); + await assert.rejects(zclNode.endpoints[1].clusters.onOff.toggle(), /device not responding/); }); it('should ignore a duplicate response without sending an error back', async function () { - const sentFrames = []; - const node = new Node({ - sendFrame: async (endpointId, clusterId, frame) => { - sentFrames.push(frame); - if (sentFrames.length > 1) return; - // The device answers with a Read Attributes Response (attribute 0x0004, SUCCESS, "_TZ3000") - // and sends that same response a second time, as some devices do - const response = Buffer.from([ - 0x18, - frame[1], - 0x01, - 0x04, - 0x00, - 0x00, - 0x42, - 0x07, - ...Buffer.from('_TZ3000'), - ]); - setImmediate(async () => { - await node.handleFrame(endpointId, clusterId, response, { dstEndpoint: 1 }); - await node.handleFrame(endpointId, clusterId, response, { dstEndpoint: 1 }); - }); - }, - endpointDescriptors: [{ endpointId: 1, inputClusters: [BasicCluster.ID] }], - }); + const { zclNode, frames, intercept, settle } = deviceServing( + BasicCluster.ID, + 'basic', + new (class extends BoundCluster { + async readAttributesStructured() { + // Attribute 0x0004, SUCCESS, string "_TZ3000" + return { + attributes: Buffer.from([0x04, 0x00, 0x00, 0x42, 0x07, ...Buffer.from('_TZ3000')]), + }; + } + })(), + ); + // The device sends its response a second time, as some devices do + intercept(({ from, raw }) => (from === 'virtualDevice' ? [raw, raw] : undefined)); - const { basic } = node.endpoints[1].clusters; - const response = await basic.readAttributesStructured( + const response = await zclNode.endpoints[1].clusters.basic.readAttributesStructured( { attributes: [{ attributeId: 4, indexPath: [] }] }, { timeout: 1000 }, ); - await new Promise(setImmediate); + await settle(); assert.strictEqual(response.attributes.subarray(5).toString(), '_TZ3000'); // Only the request went out, no error Default Response for the second copy - assert.strictEqual(sentFrames.length, 1); + assert.strictEqual(frames.filter(({ from }) => from === 'zclNode').length, 1); }); }); diff --git a/test/testMfgSpecificCommands.js b/test/testMfgSpecificCommands.js index 8bf9f29e..0f0f5359 100644 --- a/test/testMfgSpecificCommands.js +++ b/test/testMfgSpecificCommands.js @@ -73,291 +73,181 @@ class IkeaSceneCluster extends SceneCluster { } Cluster.addCluster(IkeaSceneCluster); -const { createMockNode } = require('./util'); +const { createVirtualDevice } = require('./util'); + +/** + * Sends `command` from a scenes cluster to a virtual device that answers with BoundCluster's + * defaults, and returns what went on the wire. + */ +async function send(command, bound = new BoundCluster()) { + const { zclNode, virtualDevice, frames } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters: [SceneCluster.ID] }], + }); + virtualDevice.endpoints[1].bind('scenes', bound); -const node = createMockNode({ - loopback: true, - endpoints: [ - { - endpointId: 1, - inputClusters: [SceneCluster.ID], - }, - ], -}); + const result = command(zclNode.endpoints[1].clusters.scenes); + return { result, frames }; +} -class IkeaBoundCluster extends BoundCluster { - constructor({ - onReadAttributes, - onWriteAttributes, - onConfigureReporting, - onReadReportingConfiguration, - onIkeaSceneStep, - onIkeaSceneMove, - onIkeaSceneMoveStop, - } = {}) { - super(); - if (onReadAttributes) this.readAttributes = onReadAttributes; - if (onWriteAttributes) this.writeAttributes = onWriteAttributes; - if (onConfigureReporting) this.configureReporting = onConfigureReporting; - if (onReadReportingConfiguration) { - this.readReportingConfiguration = onReadReportingConfiguration; - } - if (onIkeaSceneStep) this.ikeaSceneStep = onIkeaSceneStep; - if (onIkeaSceneMove) this.ikeaSceneMove = onIkeaSceneMove; - if (onIkeaSceneMoveStop) this.ikeaSceneMoveStop = onIkeaSceneMoveStop; - } +/** Asserts the single frame the scenes cluster sent carries `manufacturerId`. */ +async function assertSentManufacturerId(command, manufacturerId) { + const { result, frames } = await send(command); + // Only the request is under test: BoundCluster's defaults reject some of these commands. + await result.catch(() => {}); + const sent = frames.filter((frame) => frame.from === 'zclNode'); + assert.strictEqual(sent.length, 1); + assert.strictEqual(sent[0].frame.manufacturerId, manufacturerId); +} + +/** Asserts that mixing manufacturers is rejected before anything is sent. */ +async function assertRejectsMixedManufacturers(command) { + const { result, frames } = await send(command); + await assert.rejects(result); + assert.deepStrictEqual(frames, []); } describe('manufacturer specific commands', function () { - it('should execute custom commands', function (t, done) { - let commandsExecuted = 0; - const ikeaSceneStepCommand = { - mode: 'up', - stepSize: 1, - transitionTime: 13, - }; - const ikeaSceneMoveCommand = { - mode: 'up', - transitionTime: 13, - }; - const ikeaSceneMoveStop = { - duration: 1000, - }; - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onIkeaSceneStep: (payload) => { - commandsExecuted++; - assert.deepStrictEqual({ ...payload }, ikeaSceneStepCommand); - if (commandsExecuted === 3) done(); - }, - onIkeaSceneMove: (payload) => { - commandsExecuted++; - assert.deepStrictEqual({ ...payload }, ikeaSceneMoveCommand); - if (commandsExecuted === 3) done(); - }, - onIkeaSceneMoveStop: (payload) => { - commandsExecuted++; - assert.deepStrictEqual({ ...payload }, ikeaSceneMoveStop); - if (commandsExecuted === 3) done(); - }, - }), + it('should execute custom commands', async function () { + const received = {}; + const { result, frames } = await send( + async (scenes) => { + await scenes.ikeaSceneStep({ mode: 'up', stepSize: 1, transitionTime: 13 }); + await scenes.ikeaSceneMove({ mode: 'up', transitionTime: 13 }); + await scenes.ikeaSceneMoveStop({ duration: 1000 }); + }, + new (class extends BoundCluster { + async ikeaSceneStep(payload) { + received.ikeaSceneStep = { ...payload }; + } + + async ikeaSceneMove(payload) { + received.ikeaSceneMove = { ...payload }; + } + + async ikeaSceneMoveStop(payload) { + received.ikeaSceneMoveStop = { ...payload }; + } + })(), ); + await result; - node.endpoints[1].clusters['scenes'].ikeaSceneStep(ikeaSceneStepCommand); - node.endpoints[1].clusters['scenes'].ikeaSceneMove(ikeaSceneMoveCommand); - node.endpoints[1].clusters['scenes'].ikeaSceneMoveStop(ikeaSceneMoveStop); + assert.deepStrictEqual(received, { + ikeaSceneStep: { mode: 'up', stepSize: 1, transitionTime: 13 }, + ikeaSceneMove: { mode: 'up', transitionTime: 13 }, + ikeaSceneMoveStop: { duration: 1000 }, + }); + const sent = frames.filter((frame) => frame.from === 'zclNode'); + assert.deepStrictEqual( + sent.map(({ frame }) => frame.manufacturerId), + [0x117c, 0x117c, 0x117c], + ); }); describe('global commands', function () { describe('readAttributes', function () { - it('should set correct manufacturerId', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onReadAttributes: (payload, meta, rawPayload) => { - assert.strictEqual(MANUFACTURER_ID_1, rawPayload.manufacturerId); - done(); - }, - }), + it('should set correct manufacturerId', async function () { + await assertSentManufacturerId( + (scenes) => scenes.readAttributes(['sceneCount']), + MANUFACTURER_ID_1, ); - node.endpoints[1].clusters['scenes'].readAttributes(['sceneCount']); }); - it('should throw when different manufacturerIds are found', function (t, done) { - node.endpoints[1].clusters['scenes'] - .readAttributes(['sceneCount', 'currentScene']) - .catch((err) => { - assert(err instanceof Error); - done(); - }); + it('should throw when different manufacturerIds are found', async function () { + await assertRejectsMixedManufacturers((scenes) => + scenes.readAttributes(['sceneCount', 'currentScene']), + ); }); - it('should set correct manufacturerId for multiple attributes', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onReadAttributes: (payload, meta, rawPayload) => { - assert.strictEqual(MANUFACTURER_ID_2, rawPayload.manufacturerId); - done(); - }, - }), + it('should set correct manufacturerId for multiple attributes', async function () { + await assertSentManufacturerId( + (scenes) => scenes.readAttributes(['currentScene', 'currentGroup']), + MANUFACTURER_ID_2, ); - - node.endpoints[1].clusters['scenes'].readAttributes(['currentScene', 'currentGroup']); }); - it('should not set manufacturerId when zcl attribute is present', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onReadAttributes: (payload, meta, rawPayload) => { - assert.strictEqual(undefined, rawPayload.manufacturerId); - done(); - }, - }), + it('should not set manufacturerId when zcl attribute is present', async function () { + await assertSentManufacturerId( + (scenes) => scenes.readAttributes(['sceneValid']), + undefined, ); - - node.endpoints[1].clusters['scenes'].readAttributes(['sceneValid']); }); }); describe('writeAttributes', function () { - it('should set correct manufacturerId', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onWriteAttributes: (payload, meta, rawPayload) => { - assert.strictEqual(MANUFACTURER_ID_1, rawPayload.manufacturerId); - done(); - }, - }), + it('should set correct manufacturerId', async function () { + await assertSentManufacturerId( + (scenes) => scenes.writeAttributes({ sceneCount: 1 }), + MANUFACTURER_ID_1, ); - node.endpoints[1].clusters['scenes'].writeAttributes({ sceneCount: 1 }); }); - it('should throw when different manufacturerIds are found', function (t, done) { - node.endpoints[1].clusters['scenes'] - .writeAttributes({ sceneCount: 1, currentScene: 2 }) - .catch((err) => { - assert(err instanceof Error); - done(); - }); + it('should throw when different manufacturerIds are found', async function () { + await assertRejectsMixedManufacturers((scenes) => + scenes.writeAttributes({ sceneCount: 1, currentScene: 2 }), + ); }); - it('should set correct manufacturerId for multiple attributes', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onWriteAttributes: (payload, meta, rawPayload) => { - assert.strictEqual(MANUFACTURER_ID_2, rawPayload.manufacturerId); - done(); - }, - }), + it('should set correct manufacturerId for multiple attributes', async function () { + await assertSentManufacturerId( + (scenes) => scenes.writeAttributes({ currentScene: 1, currentGroup: 2 }), + MANUFACTURER_ID_2, ); - - node.endpoints[1].clusters['scenes'].writeAttributes({ currentScene: 1, currentGroup: 2 }); }); - it('should not set manufacturerId when zcl attribute is present', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onWriteAttributes: (payload, meta, rawPayload) => { - assert.strictEqual(undefined, rawPayload.manufacturerId); - done(); - }, - }), + it('should not set manufacturerId when zcl attribute is present', async function () { + await assertSentManufacturerId( + (scenes) => scenes.writeAttributes({ sceneValid: true }), + undefined, ); - - node.endpoints[1].clusters['scenes'].writeAttributes({ sceneValid: true }); }); }); describe('configureReporting', function () { - it('should set correct manufacturerId', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onConfigureReporting: (payload, meta, rawPayload) => { - assert.strictEqual(MANUFACTURER_ID_1, rawPayload.manufacturerId); - done(); - }, - }), + const reporting = { minInterval: 0, maxInterval: 300, minChange: 1 }; + + it('should set correct manufacturerId', async function () { + await assertSentManufacturerId( + (scenes) => scenes.configureReporting({ sceneCount: reporting }), + MANUFACTURER_ID_1, ); - node.endpoints[1].clusters['scenes'].configureReporting({ - sceneCount: { minInterval: 0, maxInterval: 300, minChange: 1 }, - }); }); - it('should throw when different manufacturerIds are found', function (t, done) { - node.endpoints[1].clusters['scenes'] - .configureReporting({ - sceneCount: { minInterval: 0, maxInterval: 300, minChange: 1 }, - currentScene: { minInterval: 0, maxInterval: 300, minChange: 1 }, - }) - .catch((err) => { - assert(err instanceof Error); - done(); - }); + it('should throw when different manufacturerIds are found', async function () { + await assertRejectsMixedManufacturers((scenes) => + scenes.configureReporting({ sceneCount: reporting, currentScene: reporting }), + ); }); - it('should set correct manufacturerId for multiple attributes', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onConfigureReporting: (payload, meta, rawPayload) => { - assert.strictEqual(MANUFACTURER_ID_2, rawPayload.manufacturerId); - done(); - }, - }), + it('should set correct manufacturerId for multiple attributes', async function () { + await assertSentManufacturerId( + (scenes) => + scenes.configureReporting({ currentScene: reporting, currentGroup: reporting }), + MANUFACTURER_ID_2, ); - node.endpoints[1].clusters['scenes'].configureReporting({ - currentScene: { minInterval: 0, maxInterval: 300, minChange: 1 }, - currentGroup: { minInterval: 0, maxInterval: 300, minChange: 1 }, - }); }); - it('should not set manufacturerId when zcl attribute is present', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onConfigureReporting: (payload, meta, rawPayload) => { - assert.strictEqual(undefined, rawPayload.manufacturerId); - done(); - }, - }), + it('should not set manufacturerId when zcl attribute is present', async function () { + await assertSentManufacturerId( + (scenes) => scenes.configureReporting({ sceneValid: reporting, currentGroup: reporting }), + undefined, ); - - node.endpoints[1].clusters['scenes'].configureReporting({ - sceneValid: { minInterval: 0, maxInterval: 300, minChange: 1 }, - currentGroup: { minInterval: 0, maxInterval: 300, minChange: 1 }, - }); }); }); describe('readReportingConfiguration', function () { - it('should set correct manufacturerId', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onReadReportingConfiguration: (payload, meta, rawPayload) => { - assert.strictEqual(MANUFACTURER_ID_1, rawPayload.manufacturerId); - done(); - }, - }), + it('should set correct manufacturerId', async function () { + await assertSentManufacturerId( + (scenes) => scenes.readReportingConfiguration(['sceneCount']), + MANUFACTURER_ID_1, ); - node.endpoints[1].clusters['scenes'].readReportingConfiguration(['sceneCount']); }); - it('should throw when different manufacturerIds are found', function (t, done) { - node.endpoints[1].clusters['scenes'] - .readReportingConfiguration(['sceneCount', 'currentScene']) - .catch((err) => { - assert(err instanceof Error); - done(); - }); + it('should throw when different manufacturerIds are found', async function () { + await assertRejectsMixedManufacturers((scenes) => + scenes.readReportingConfiguration(['sceneCount', 'currentScene']), + ); }); - it('should set correct manufacturerId for multiple attributes', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onReadReportingConfiguration: (payload, meta, rawPayload) => { - assert.strictEqual(MANUFACTURER_ID_2, rawPayload.manufacturerId); - done(); - }, - }), + it('should set correct manufacturerId for multiple attributes', async function () { + await assertSentManufacturerId( + (scenes) => scenes.readReportingConfiguration(['currentGroup', 'currentScene']), + MANUFACTURER_ID_2, ); - node.endpoints[1].clusters['scenes'].readReportingConfiguration([ - 'currentGroup', - 'currentScene', - ]); }); - it('should not set manufacturerId when zcl attribute is present', function (t, done) { - node.endpoints[1].bind( - 'scenes', - new IkeaBoundCluster({ - onReadReportingConfiguration: (payload, meta, rawPayload) => { - assert.strictEqual(undefined, rawPayload.manufacturerId); - done(); - }, - }), + it('should not set manufacturerId when zcl attribute is present', async function () { + await assertSentManufacturerId( + (scenes) => scenes.readReportingConfiguration(['currentGroup', 'sceneValid']), + undefined, ); - - node.endpoints[1].clusters['scenes'].readReportingConfiguration([ - 'currentGroup', - 'sceneValid', - ]); }); }); }); diff --git a/test/testNode.js b/test/testNode.js index d0a9738a..b6d2aef2 100644 --- a/test/testNode.js +++ b/test/testNode.js @@ -1,123 +1,58 @@ 'use strict'; -const { describe, it, before, afterEach, mock } = require('node:test'); +const { describe, it, mock } = require('node:test'); const assert = require('node:assert'); -const { createMockNode, createConnectedNodePair } = require('./util'); -let { debug } = require('./util'); +const { createVirtualDevice } = require('./util'); const BoundCluster = require('../lib/BoundCluster'); const Cluster = require('../lib/Cluster'); const { ZCLDataTypes } = require('../lib/zclTypes'); -const Endpoint = require('../lib/Endpoint'); const BasicCluster = require('../lib/clusters/basic'); const OnOffCluster = require('../lib/clusters/onOff'); -debug = debug.extend('test-node'); +// Cluster-specific, client to server, TSN 1, command 0x02 (toggle) +const TOGGLE = Buffer.from([0x01, 0x01, 0x02]); -describe('Node', function () { - let mockNode; - let receivingNode; - let sendingNode; - before(function () { - mockNode = createMockNode({ - loopback: true, - endpoints: [ - { - endpointId: 1, - inputClusters: [BasicCluster.ID], - }, - ], - }); - - [sendingNode, receivingNode] = createConnectedNodePair( - { - endpoints: [ - { - endpointId: 1, - inputClusters: [BasicCluster.ID, OnOffCluster.ID], - }, - ], - }, - { - endpoints: [ - { - endpointId: 1, - inputClusters: [BasicCluster.ID, OnOffCluster.ID], - }, - ], - }, - ); - // Override log id for sending node - const sendingNodeLog = (...args) => `sending-node:${args.join(':')}`; - sendingNode.getLogId = sendingNodeLog; - sendingNode.endpoints[1].getLogId = sendingNodeLog; - // Override log id for receiving node - const receivingNodeLog = (...args) => `receiving-node:${args.join(':')}`; - receivingNode.getLogId = receivingNodeLog; - receivingNode.endpoints[1].getLogId = receivingNodeLog; - }); - afterEach(function () { - mock.restoreAll(); +/** A device with a basic cluster, served by `basic` when given. */ +function basicDevice(basic) { + return createVirtualDevice({ + endpoints: [ + { endpointId: 1, inputClusters: [BasicCluster.ID], clusters: basic ? { basic } : {} }, + ], }); - it('should fail for unbound cluster', async function () { - try { - await mockNode.endpoints[1].clusters['basic'].configureReporting({ - zclVersion: { - minInterval: 1234, - maxInterval: 4321, - }, - }); - } catch { - return; - } - throw new Error("didn't throw"); - }); - - it('should fail for unimplemented command', async function () { - mockNode.endpoints[1].bind('basic', new BoundCluster()); +} - try { - await mockNode.endpoints[1].clusters['basic'].factoryReset(); - } catch { - return; - } - throw new Error("didn't throw"); +/** A remote that sends onOff commands to the node under test, which serves them with `onOff`. */ +function remoteSendingTo(onOff) { + const virtual = createVirtualDevice({ + endpoints: [{ endpointId: 1, outputClusters: [OnOffCluster.ID] }], }); + virtual.zclNode.endpoints[1].bind('onOff', onOff); + return virtual; +} - it('should invoke command', async function () { - mockNode.endpoints[1].bind( - 'basic', - new (class extends BoundCluster { - async factoryReset() { - debug('factory reset'); - } - })(), - ); - - await mockNode.endpoints[1].clusters['basic'].factoryReset(); - }); +const summary = (frames) => frames.map(({ from, frame }) => [from, frame.cmdId]); +describe('Node', function () { it('should configure reporting', async function () { - mockNode.endpoints[1].bind( - 'basic', + let received; + const { zclNode } = basicDevice( new (class extends BoundCluster { async configureReporting({ reports }) { - assert.equal(reports.length, 1, 'exactly 1 report'); + received = reports; } })(), ); - await mockNode.endpoints[1].clusters['basic'].configureReporting({ - zclVersion: { - minInterval: 10, - maxInterval: 4321, - }, + await zclNode.endpoints[1].clusters.basic.configureReporting({ + zclVersion: { minInterval: 10, maxInterval: 4321 }, }); + + assert.strictEqual(received.length, 1, 'exactly 1 report'); }); it('should read attributes', async function () { - mockNode.endpoints[1].bind( - 'basic', + const { zclNode } = basicDevice( new (class extends BoundCluster { constructor() { super(); @@ -134,7 +69,7 @@ describe('Node', function () { })(), ); - const res = await mockNode.endpoints[1].clusters['basic'].readAttributes([ + const res = await zclNode.endpoints[1].clusters.basic.readAttributes([ 'modelId', 'manufacturerName', 'clusterRevision', @@ -142,15 +77,14 @@ describe('Node', function () { 'dateCode', ]); assert.equal(res.modelId, 'test', 'modelId should be test'); - assert.equal(res.manufacturerName, 'Athom', 'manufacturerName should be test'); - assert.equal(res.clusterRevision, 1, 'clusterRevision should be test'); + assert.equal(res.manufacturerName, 'Athom', 'manufacturerName should be Athom'); + assert.equal(res.clusterRevision, 1, 'clusterRevision should be 1'); assert.equal(res.zclVersion, undefined, 'zclVersion should not be present'); - assert.equal(res.dateCode, '1234', 'dateCode should not be present'); + assert.equal(res.dateCode, '1234', 'dateCode should be 1234'); }); it('should read attributes by numeric ID', async function () { - mockNode.endpoints[1].bind( - 'basic', + const { zclNode } = basicDevice( new (class extends BoundCluster { get modelId() { return 'numericTest'; @@ -159,13 +93,12 @@ describe('Node', function () { ); // modelId has attribute ID 5 - const res = await mockNode.endpoints[1].clusters['basic'].readAttributes([5]); + const res = await zclNode.endpoints[1].clusters.basic.readAttributes([5]); assert.equal(res[5], 'numericTest', 'numeric ID 5 should return modelId value'); }); it('should read attributes with mixed strings and numeric IDs', async function () { - mockNode.endpoints[1].bind( - 'basic', + const { zclNode } = basicDevice( new (class extends BoundCluster { get modelId() { return 'mixedTest'; @@ -178,17 +111,13 @@ describe('Node', function () { ); // manufacturerName by name, modelId (ID 5) by numeric ID - const res = await mockNode.endpoints[1].clusters['basic'].readAttributes([ - 'manufacturerName', - 5, - ]); + const res = await zclNode.endpoints[1].clusters.basic.readAttributes(['manufacturerName', 5]); assert.equal(res.manufacturerName, 'Athom', 'manufacturerName should resolve by name'); assert.equal(res[5], 'mixedTest', 'numeric ID 5 should return modelId value'); }); it('should return both name and numeric key when same attribute requested both ways', async function () { - mockNode.endpoints[1].bind( - 'basic', + const { zclNode } = basicDevice( new (class extends BoundCluster { get modelId() { return 'both'; @@ -196,56 +125,31 @@ describe('Node', function () { })(), ); - const res = await mockNode.endpoints[1].clusters['basic'].readAttributes(['modelId', 5]); + const res = await zclNode.endpoints[1].clusters.basic.readAttributes(['modelId', 5]); assert.equal(res.modelId, 'both', 'modelId key should exist'); assert.equal(res[5], 'both', 'numeric key 5 should also exist'); }); it('should read an unregistered attribute by numeric ID', async function () { - mockNode.endpoints[1].bind('basic', new (class extends BoundCluster {})()); + const { zclNode } = basicDevice(new BoundCluster()); // 0x1234 is not registered in the basic cluster, expect no error and empty/missing result - const res = await mockNode.endpoints[1].clusters['basic'].readAttributes([0x1234]); + const res = await zclNode.endpoints[1].clusters.basic.readAttributes([0x1234]); assert.equal(res[0x1234], undefined, 'unregistered attribute should not have a value'); }); it('should throw for invalid numeric attribute IDs', async function () { - mockNode.endpoints[1].bind('basic', new (class extends BoundCluster {})()); + const { zclNode, frames } = basicDevice(new BoundCluster()); + const { basic } = zclNode.endpoints[1].clusters; - await assert.rejects(() => mockNode.endpoints[1].clusters['basic'].readAttributes([-1]), { - name: 'TypeError', - }); - await assert.rejects(() => mockNode.endpoints[1].clusters['basic'].readAttributes([0x10000]), { - name: 'TypeError', - }); - await assert.rejects(() => mockNode.endpoints[1].clusters['basic'].readAttributes([1.5]), { - name: 'TypeError', - }); - await assert.rejects(() => mockNode.endpoints[1].clusters['basic'].readAttributes([NaN]), { - name: 'TypeError', - }); - }); - - it('should respond with read attribute response when disableDefaultResponse is true', async function () { - receivingNode.endpoints[1].bind( - 'basic', - new (class extends BoundCluster { - get modelId() { - return 'test'; - } - })(), - ); - - const readAttributesResponse = await sendingNode.endpoints[1].clusters['basic'].readAttributes( - ['modelId'], - { disableDefaultResponse: true }, - ); - assert.equal(readAttributesResponse.modelId, 'test'); + for (const id of [-1, 0x10000, 1.5, NaN]) { + await assert.rejects(() => basic.readAttributes([id]), { name: 'TypeError' }); + } + assert.deepStrictEqual(frames, [], 'nothing should be sent'); }); - it('should not respond with a default response when disableDefaultResponse is true', async function () { - receivingNode.endpoints[1].bind( - 'basic', + it('should read attributes without a default response when disableDefaultResponse is true', async function () { + const { zclNode, frames, settle } = basicDevice( new (class extends BoundCluster { get modelId() { return 'test'; @@ -253,159 +157,101 @@ describe('Node', function () { })(), ); - const sendingNodeSendFrameSpy = mock.method(sendingNode, 'sendFrame'); - const receivingNodeSendFrameSpy = mock.method(receivingNode, 'sendFrame'); - - await sendingNode.endpoints[1].clusters['basic'].readAttributes(['modelId'], { + const result = await zclNode.endpoints[1].clusters.basic.readAttributes(['modelId'], { disableDefaultResponse: true, }); - - // 1 frame sent by sending node, the read attributes command - assert.equal(sendingNodeSendFrameSpy.mock.callCount(), 1); - // 1 frame sent by receiving node, the read attributes response - assert.equal(receivingNodeSendFrameSpy.mock.callCount(), 1); - - // Parse the received frame - const frame = Endpoint.parseFrame(receivingNodeSendFrameSpy.mock.calls[0].arguments[2]); - - // 0x01 is read attributes response - assert.equal(frame.cmdId, 1); - }); - - it('should not respond to a default response frame', async function () { - receivingNode.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - toggle() { - debug('toggled'); - } - })(), - ); - - const sendingNodeSendFrameSpy = mock.method(sendingNode, 'sendFrame'); - const receivingNodeSendFrameSpy = mock.method(receivingNode, 'sendFrame'); - - await sendingNode.endpoints[1].clusters['onOff'].toggle(); - // 1 frame sent by sending node, the toggle command - assert.equal(sendingNodeSendFrameSpy.mock.callCount(), 1); - // 1 frame sent by receiving node, the toggle command default response - assert.equal(receivingNodeSendFrameSpy.mock.callCount(), 1); - - // Parse the received frame - const frame = Endpoint.parseFrame(receivingNodeSendFrameSpy.mock.calls[0].arguments[2]); - - // 0x01 is read attributes response - assert.equal(frame.cmdId, 11); - }); - - it('should respond with default response error when frame could not be handled', async function () { - receivingNode.endpoints[1].bind('onOff', new (class extends BoundCluster {})()); - - const sendingNodeHandleFrameSpy = mock.method(sendingNode, 'handleFrame'); - - // Execute toggle and expect UNSUP_COMMAND as the command is not implemented (ZCL R8 2.5.12.2) - await assert.rejects(sendingNode.endpoints[1].clusters['onOff'].toggle(), { - message: 'UNSUP_CLUSTER_COMMAND', - }); - - // 1 frame received by sending node, the default response error - assert.equal(sendingNodeHandleFrameSpy.mock.callCount(), 1); - - // Parse the received frame - const frame = Endpoint.parseFrame(sendingNodeHandleFrameSpy.mock.calls[0].arguments[2]); - assert.equal(frame.cmdId, 0x0b); - assert.equal(frame.data[1], 0x81); // UNSUP_COMMAND + await settle(); + + assert.deepStrictEqual(result, { modelId: 'test' }); + assert.strictEqual(frames[0].frame.frameControl.disableDefaultResponse, true); + // Read attributes, then its response and nothing else + assert.deepStrictEqual(summary(frames), [ + ['zclNode', 0x00], + ['virtualDevice', 0x01], + ]); }); it('should not respond to a group cast frame', async function () { - const toggledSpy = mock.fn(); - receivingNode.endpoints[1].bind( - 'onOff', + const toggled = mock.fn(); + const { virtualDevice, frames, settle } = remoteSendingTo( new (class extends BoundCluster { toggle() { - toggledSpy(); + toggled(); } })(), ); - const receivingNodeSendFrameSpy = mock.method(receivingNode, 'sendFrame'); + await virtualDevice.sendFrame(1, OnOffCluster.ID, TOGGLE, { groupId: 0x01 }); + await settle(); - // Send toggle command to onOff cluster with group id - await receivingNode.handleFrame(1, 6, Buffer.from([0x01, 0x01, 0x02]), { groupId: 0x01 }); - assert.equal(toggledSpy.mock.callCount(), 1); - // 0 frames sent by receiving node, no response to group commands - assert.equal(receivingNodeSendFrameSpy.mock.callCount(), 0); + assert.strictEqual(toggled.mock.callCount(), 1); + assert.deepStrictEqual(summary(frames), [['virtualDevice', 0x02]]); }); it('should not send a default response to a broadcast frame', async function () { - const toggledSpy = mock.fn(); - receivingNode.endpoints[1].bind( - 'onOff', + const toggled = mock.fn(); + const { virtualDevice, frames, settle } = remoteSendingTo( new (class extends BoundCluster { toggle() { - toggledSpy(); + toggled(); } })(), ); - const receivingNodeSendFrameSpy = mock.method(receivingNode, 'sendFrame'); + await virtualDevice.sendFrame(1, OnOffCluster.ID, TOGGLE, { broadcast: true, dstEndpoint: 1 }); + await settle(); - const meta = { broadcast: true, dstEndpoint: 1 }; - await receivingNode.handleFrame(1, 6, Buffer.from([0x01, 0x01, 0x02]), meta); - assert.equal(toggledSpy.mock.callCount(), 1); - assert.equal(receivingNodeSendFrameSpy.mock.callCount(), 0); + assert.strictEqual(toggled.mock.callCount(), 1); + assert.deepStrictEqual(summary(frames), [['virtualDevice', 0x02]]); }); it('should not send a default response error to a broadcast frame', async function () { - receivingNode.endpoints[1].bind('onOff', new (class extends BoundCluster {})()); - - const receivingNodeSendFrameSpy = mock.method(receivingNode, 'sendFrame'); + const { virtualDevice, frames, settle } = remoteSendingTo(new BoundCluster()); // toggle is not implemented, so this would otherwise get a default response error - await receivingNode.handleFrame(1, 6, Buffer.from([0x01, 0x01, 0x02]), { broadcast: true }); - assert.equal(receivingNodeSendFrameSpy.mock.callCount(), 0); + await virtualDevice.sendFrame(1, OnOffCluster.ID, TOGGLE, { broadcast: true }); + await settle(); + + assert.deepStrictEqual(summary(frames), [['virtualDevice', 0x02]]); }); it('should send a default response to a unicast frame for the broadcast endpoint', async function () { - receivingNode.endpoints[1].bind( - 'onOff', + const { virtualDevice, frames, settle } = remoteSendingTo( new (class extends BoundCluster { toggle() {} })(), ); - const receivingNodeSendFrameSpy = mock.method(receivingNode, 'sendFrame'); + await virtualDevice.sendFrame(1, OnOffCluster.ID, TOGGLE, { dstEndpoint: 0xff }); + await settle(); - await receivingNode.handleFrame(1, 6, Buffer.from([0x01, 0x01, 0x02]), { dstEndpoint: 0xff }); - assert.equal(receivingNodeSendFrameSpy.mock.callCount(), 1); + assert.deepStrictEqual(summary(frames), [ + ['virtualDevice', 0x02], + ['zclNode', 0x0b], + ]); }); it('should write attributes', async function () { - mockNode.endpoints[1].bind( - 'basic', + let written; + const { zclNode } = basicDevice( new (class extends BoundCluster { get modelId() { return 'test'; } - get manufacturerName() { - return 'Athom'; - } - - set modelId(val) { - assert.equal(val, 'test1'); + set modelId(value) { + written = value; } })(), ); - await mockNode.endpoints[1].clusters['basic'].writeAttributes({ - modelId: 'test1', - }); + await zclNode.endpoints[1].clusters.basic.writeAttributes({ modelId: 'test1' }); + + assert.strictEqual(written, 'test1'); }); it('should discover attributes', async function () { - mockNode.endpoints[1].bind( - 'basic', + const { zclNode } = basicDevice( new (class extends BoundCluster { get modelId() { return 'test'; @@ -414,45 +260,65 @@ describe('Node', function () { get manufacturerName() { return 'Athom'; } - - set modelId(val) { - assert.equal(val, 'test1'); - } })(), ); - const attrs = await mockNode.endpoints[1].clusters['basic'].discoverAttributes(); + const attrs = await zclNode.endpoints[1].clusters.basic.discoverAttributes(); ['modelId', 'manufacturerName'].forEach((a) => { assert(attrs.includes(a), `${a} is missing`); }); }); it('should discover received commands', async function () { - mockNode.endpoints[1].bind( - 'basic', + const { zclNode } = basicDevice( new (class extends BoundCluster { - async factoryReset() { - debug('factory reset'); - } + async factoryReset() {} })(), ); - const cmds = await mockNode.endpoints[1].clusters['basic'].discoverCommandsReceived(); + + const cmds = await zclNode.endpoints[1].clusters.basic.discoverCommandsReceived(); assert(cmds.includes('factoryReset')); }); - it('should support adding a Cluster with string attribute with 0x0000 ID', async function () { + // A noData (0x00) record must not be decoded with the attribute's own type, which for an + // attribute with id 0x0000 used to be mistaken for the record's type. + it('should decode a noData report for an attribute with id 0x0000', async function (t) { class MyCluster extends Cluster { static get ID() { return 0x1234; } + static get NAME() { + return 'myCluster'; + } + static get ATTRIBUTES() { return { priceOption: { id: 0x0000, type: ZCLDataTypes.string }, }; } } - Cluster.addCluster(MyCluster); + t.after(() => Cluster.removeCluster(MyCluster.ID)); + + const { zclNode, virtualDevice, frames, settle } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters: [MyCluster.ID] }], + }); + const onPriceOption = mock.fn(); + zclNode.endpoints[1].clusters.myCluster.on('attr.priceOption', onPriceOption); + + // Report, server to client: attribute 0x0000, data type 0x00 (noData), no value + await virtualDevice.sendFrame( + 1, + MyCluster.ID, + Buffer.from([0x18, 0x01, 0x0a, 0x00, 0x00, 0x00]), + ); + await settle(); + + assert.strictEqual(onPriceOption.mock.callCount(), 1); + assert.deepStrictEqual( + frames.map(({ from }) => from), + ['virtualDevice'], + ); }); }); diff --git a/test/testTrxSequenceNumber.js b/test/testTrxSequenceNumber.js index 0fc29ca7..dfececc3 100644 --- a/test/testTrxSequenceNumber.js +++ b/test/testTrxSequenceNumber.js @@ -1,67 +1,46 @@ 'use strict'; -const { describe, it, beforeEach, afterEach, mock } = require('node:test'); +const { describe, it } = require('node:test'); const assert = require('node:assert'); -const Node = require('../lib/Node'); const BoundCluster = require('../lib/BoundCluster'); const BasicCluster = require('../lib/clusters/basic'); const OnOffCluster = require('../lib/clusters/onOff'); +const { createVirtualDevice } = require('./util'); describe('Transaction sequence number', function () { - let receivingNode; - let sendingNode; - let sentTrxSequenceNumbers; - - beforeEach(function () { - sentTrxSequenceNumbers = []; - sendingNode = new Node({ - sendFrame: (endpointId, clusterId, frame) => { - sentTrxSequenceNumbers.push(frame[1]); - receivingNode.handleFrame(endpointId, clusterId, frame); - }, - endpointDescriptors: [ - { endpointId: 1, inputClusters: [OnOffCluster.ID] }, - { endpointId: 2, inputClusters: [BasicCluster.ID] }, - ], - }); - - receivingNode = new Node({ - sendFrame: (...args) => { - sendingNode.handleFrame(...args); - }, - endpointDescriptors: [ - { endpointId: 1, inputClusters: [OnOffCluster.ID] }, + it('should share one counter across endpoints and clusters', async function () { + const { zclNode, frames } = createVirtualDevice({ + endpoints: [ + { + endpointId: 1, + inputClusters: [OnOffCluster.ID], + clusters: { + onOff: new (class extends BoundCluster { + toggle() {} + })(), + }, + }, { endpointId: 2, inputClusters: [BasicCluster.ID] }, ], }); - receivingNode.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - toggle() {} - })(), - ); - }); - afterEach(function () { - mock.restoreAll(); - }); - - it('should share one counter across endpoints and clusters', async function () { - await sendingNode.endpoints[1].clusters.onOff.toggle(); - const { basic } = sendingNode.endpoints[2].clusters; - await basic.factoryReset(undefined, { waitForResponse: false }); - await sendingNode.endpoints[1].clusters.onOff.toggle(); + await zclNode.endpoints[1].clusters.onOff.toggle(); + await zclNode.endpoints[2].clusters.basic.factoryReset(undefined, { waitForResponse: false }); + await zclNode.endpoints[1].clusters.onOff.toggle(); - const [first] = sentTrxSequenceNumbers; - assert.deepStrictEqual(sentTrxSequenceNumbers, [first, (first + 1) % 256, (first + 2) % 256]); + const sent = frames + .filter(({ from }) => from === 'zclNode') + .map(({ frame }) => frame.trxSequenceNumber); + const [first] = sent; + assert.deepStrictEqual(sent, [first, (first + 1) % 256, (first + 2) % 256]); }); - it('should start at a random value and wrap around', function () { - mock.method(Math, 'random', () => 0.999); - const node = new Node({ sendFrame: () => {}, endpointDescriptors: [] }); + it('should start at a random value and wrap around', function (t) { + t.mock.method(Math, 'random', () => 0.999); + const { zclNode } = createVirtualDevice({ endpoints: [] }); - assert.strictEqual(node.nextSeqNr(), 0); - assert.strictEqual(node.nextSeqNr(), 1); + assert.strictEqual(zclNode.nextSeqNr(), 0); + assert.strictEqual(zclNode.nextSeqNr(), 1); }); }); diff --git a/test/testZCLError.js b/test/testZCLError.js index 108aea28..5924935c 100644 --- a/test/testZCLError.js +++ b/test/testZCLError.js @@ -3,313 +3,131 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); -const Node = require('../lib/Node'); const OnOffCluster = require('../lib/clusters/onOff'); const LevelControlCluster = require('../lib/clusters/levelControl'); const IasZoneCluster = require('../lib/clusters/iasZone'); const { ZCLStandardHeader } = require('../lib/zclFrames'); const { ZCLError } = require('../lib/util'); -const { createMockNode } = require('./util'); +const { createVirtualDevice } = require('./util'); -/** - * Creates a connected node pair where frames sent from the device back to the controller - * are captured before being forwarded. - */ -function createSpyNodePair() { - const capturedFrames = []; - let controllerNode; - let deviceNode; +const SET_ON_CMD_ID = OnOffCluster.COMMANDS.setOn.id; +const DEFAULT_RESPONSE_CMD_ID = 0x0b; - const mockController = { - sendFrame: (endpointId, clusterId, data) => deviceNode.handleFrame(endpointId, clusterId, data), - endpointDescriptors: [{ endpointId: 1, inputClusters: [OnOffCluster.ID], outputClusters: [] }], - }; - - const mockDevice = { - sendFrame: (endpointId, clusterId, data) => { - capturedFrames.push({ endpointId, clusterId, data }); - return controllerNode.handleFrame(endpointId, clusterId, data); - }, - endpointDescriptors: [{ endpointId: 1, inputClusters: [OnOffCluster.ID], outputClusters: [] }], - }; +/** A device whose onOff cluster is served by `onOff`, when given. */ +function onOffDevice(onOff) { + return createVirtualDevice({ + endpoints: [ + { endpointId: 1, inputClusters: [OnOffCluster.ID], clusters: onOff ? { onOff } : {} }, + ], + }); +} - controllerNode = new Node(mockController); - deviceNode = new Node(mockDevice); +/** A device whose setOn handler throws `error`. */ +function onOffDeviceThrowing(error) { + return onOffDevice( + new (class extends BoundCluster { + async setOn() { + throw error; + } + })(), + ); +} - return { controllerNode, deviceNode, capturedFrames }; +/** The single Default Response the virtual device sent, as [command id it answers, status]. */ +function deviceDefaultResponse(frames) { + const responses = frames.filter(({ from }) => from === 'virtualDevice'); + assert.strictEqual(responses.length, 1); + assert.strictEqual(responses[0].frame.cmdId, DEFAULT_RESPONSE_CMD_ID); + return [...responses[0].frame.data]; } -function makeToClientFrame(cmdId) { +/** Has the device send a cluster-specific server-to-client frame; returns the node's answer. */ +async function nodeAnswerTo(clusterId, cmdId, data, inputClusters = [OnOffCluster.ID]) { + const { virtualDevice, frames, settle } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters }], + }); const header = new ZCLStandardHeader(); header.frameControl.clusterSpecific = true; header.frameControl.directionToClient = true; header.trxSequenceNumber = 1; header.cmdId = cmdId; - header.data = Buffer.alloc(0); - return header.toBuffer(); -} - -describe('ZCLError handling', function () { - it('should return ZCLError status code in Default Response when BoundCluster throws ZCLError', async function () { - const node = createMockNode({ - loopback: true, - endpoints: [ - { - endpointId: 1, - inputClusters: [OnOffCluster.ID], - }, - ], - }); + header.data = data; - node.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - async setOn() { - throw new ZCLError('NOT_AUTHORIZED'); - } - })(), - ); + await virtualDevice.sendFrame(1, clusterId, header.toBuffer()); + await settle(); - await assert.rejects( - () => node.endpoints[1].clusters.onOff.setOn(), - (err) => { - assert.strictEqual(err.message, 'NOT_AUTHORIZED'); - return true; - }, - ); - }); + const answers = frames.filter(({ from }) => from === 'zclNode'); + assert.strictEqual(answers.length, 1); + assert.strictEqual(answers[0].frame.cmdId, DEFAULT_RESPONSE_CMD_ID); + return [...answers[0].frame.data]; +} - it('should return FAILURE status in Default Response when BoundCluster throws a generic Error', async function () { - const node = createMockNode({ - loopback: true, - endpoints: [ - { - endpointId: 1, - inputClusters: [OnOffCluster.ID], - }, - ], +describe('ZCLError handling', function () { + // ZCL status bytes from the spec, not from our own status table + for (const [description, error, status, statusByte] of [ + ['a ZCLError', new ZCLError('NOT_AUTHORIZED'), 'NOT_AUTHORIZED', 0x7e], + ['a ZCLError', new ZCLError('INVALID_VALUE'), 'INVALID_VALUE', 0x87], + ['a ZCLError', new ZCLError('INSUFFICIENT_SPACE'), 'INSUFFICIENT_SPACE', 0x89], + ['a generic Error', new Error('something went wrong'), 'FAILURE', 0x01], + ]) { + it(`should answer ${status} (0x${statusByte.toString(16)}) when BoundCluster throws ${description}`, async function () { + const { zclNode, frames } = onOffDeviceThrowing(error); + + await assert.rejects(zclNode.endpoints[1].clusters.onOff.setOn(), { message: status }); + + assert.deepStrictEqual(deviceDefaultResponse(frames), [SET_ON_CMD_ID, statusByte]); }); - - node.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - async setOn() { - throw new Error('something went wrong'); - } - })(), - ); - - await assert.rejects( - () => node.endpoints[1].clusters.onOff.setOn(), - (err) => { - assert.strictEqual(err.message, 'FAILURE'); - return true; - }, - ); - }); - - it('should encode ZCLError status code in Default Response frame bytes', async function () { - // NOT_AUTHORIZED = 0x7e per ZCL spec - const NOT_AUTHORIZED_STATUS_BYTE = 0x7e; - const SET_ON_CMD_ID = OnOffCluster.COMMANDS.setOn.id; - const DEFAULT_RESPONSE_CMD_ID = 0x0b; - - const { controllerNode, deviceNode, capturedFrames } = createSpyNodePair(); - - deviceNode.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - async setOn() { - throw new ZCLError('NOT_AUTHORIZED'); - } - })(), - ); - - await assert.rejects(() => controllerNode.endpoints[1].clusters.onOff.setOn()); - - // capturedFrames only contains frames sent from the device back to the controller. - // Find the Default Response frame among the captured device → controller frames. - const defaultResponseFrame = capturedFrames - .map((f) => ZCLStandardHeader.fromBuffer(f.data)) - .find((frame) => frame.cmdId === DEFAULT_RESPONSE_CMD_ID); - - assert.ok(defaultResponseFrame, 'Default Response frame was sent'); - assert.strictEqual( - defaultResponseFrame.data[0], - SET_ON_CMD_ID, - 'Default Response should reference the received setOn command ID', - ); - assert.strictEqual( - defaultResponseFrame.data[1], - NOT_AUTHORIZED_STATUS_BYTE, - 'Default Response should encode NOT_AUTHORIZED (0x7e) as the status byte', - ); - }); - - it('should encode FAILURE (0x01) in Default Response frame bytes when a generic Error is thrown', async function () { - const FAILURE_STATUS_BYTE = 0x01; - const SET_ON_CMD_ID = OnOffCluster.COMMANDS.setOn.id; - const DEFAULT_RESPONSE_CMD_ID = 0x0b; - - const { controllerNode, deviceNode, capturedFrames } = createSpyNodePair(); - - deviceNode.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - async setOn() { - throw new Error('something went wrong'); - } - })(), - ); - - await assert.rejects(() => controllerNode.endpoints[1].clusters.onOff.setOn()); - - const defaultResponseFrame = capturedFrames - .map((f) => ZCLStandardHeader.fromBuffer(f.data)) - .find((frame) => frame.cmdId === DEFAULT_RESPONSE_CMD_ID); - - assert.ok(defaultResponseFrame, 'Default Response frame was sent'); - assert.strictEqual( - defaultResponseFrame.data[0], - SET_ON_CMD_ID, - 'Default Response should reference the received setOn command ID', - ); - assert.strictEqual( - defaultResponseFrame.data[1], - FAILURE_STATUS_BYTE, - 'Default Response should encode FAILURE (0x01) as the status byte', - ); - }); + } it('should send SUCCESS status byte when ZCLError("SUCCESS") is thrown', async function () { - const SUCCESS_STATUS_BYTE = 0x00; - const SET_ON_CMD_ID = OnOffCluster.COMMANDS.setOn.id; - const DEFAULT_RESPONSE_CMD_ID = 0x0b; - - const { controllerNode, deviceNode, capturedFrames } = createSpyNodePair(); + const { zclNode, frames } = onOffDeviceThrowing(new ZCLError('SUCCESS')); - deviceNode.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - async setOn() { - throw new ZCLError('SUCCESS'); - } - })(), - ); + // A SUCCESS default response resolves the command + await zclNode.endpoints[1].clusters.onOff.setOn(); - // The controller should not reject — a SUCCESS default response resolves the command - await controllerNode.endpoints[1].clusters.onOff.setOn(); - - const defaultResponseFrame = capturedFrames - .map((f) => ZCLStandardHeader.fromBuffer(f.data)) - .find((frame) => frame.cmdId === DEFAULT_RESPONSE_CMD_ID); - - assert.ok(defaultResponseFrame, 'Default Response frame was sent'); - assert.strictEqual( - defaultResponseFrame.data[0], - SET_ON_CMD_ID, - 'Default Response should reference the received setOn command ID', - ); - assert.strictEqual( - defaultResponseFrame.data[1], - SUCCESS_STATUS_BYTE, - 'Default Response should encode SUCCESS (0x00) as the status byte', - ); + assert.deepStrictEqual(deviceDefaultResponse(frames), [SET_ON_CMD_ID, 0x00]); }); // ZCL R8 2.5.12.2 it('should send UNSUP_COMMAND (0x81) for a command the BoundCluster does not implement', async function () { - const { controllerNode, deviceNode, capturedFrames } = createSpyNodePair(); - deviceNode.endpoints[1].bind('onOff', new BoundCluster()); + const { zclNode, frames } = onOffDevice(new BoundCluster()); - await assert.rejects(() => controllerNode.endpoints[1].clusters.onOff.setOn(), { + await assert.rejects(zclNode.endpoints[1].clusters.onOff.setOn(), { message: 'UNSUP_CLUSTER_COMMAND', }); - const defaultResponseFrame = ZCLStandardHeader.fromBuffer(capturedFrames[0].data); - assert.strictEqual(defaultResponseFrame.cmdId, 0x0b); - assert.strictEqual(defaultResponseFrame.data[1], 0x81); + assert.deepStrictEqual(deviceDefaultResponse(frames), [SET_ON_CMD_ID, 0x81]); }); it('should send UNSUP_COMMAND (0x81) for an unknown command id on a client cluster', async function () { - const { deviceNode, capturedFrames } = createSpyNodePair(); - await deviceNode.handleFrame(1, OnOffCluster.ID, makeToClientFrame(0x7f), {}); - assert.strictEqual(ZCLStandardHeader.fromBuffer(capturedFrames[0].data).data[1], 0x81); + const answer = await nodeAnswerTo(OnOffCluster.ID, 0x7f, Buffer.alloc(0)); + assert.deepStrictEqual(answer, [0x7f, 0x81]); }); // A known request without a handler is still unsupported; only responses without a pending // request are ignored it('should send UNSUP_COMMAND (0x81) for a server-to-client request without a handler', async function () { - const capturedFrames = []; - const node = new Node({ - sendFrame: async (endpointId, clusterId, data) => { - capturedFrames.push(data); - }, - endpointDescriptors: [{ endpointId: 1, inputClusters: [IasZoneCluster.ID] }], - }); - const header = new ZCLStandardHeader(); - header.frameControl.clusterSpecific = true; - header.frameControl.directionToClient = true; - header.trxSequenceNumber = 1; - header.cmdId = 0x00; // zoneStatusChangeNotification - header.data = Buffer.from([0x01, 0x00, 0x00, 0x01, 0x00, 0x00]); - - await node.handleFrame(1, IasZoneCluster.ID, header.toBuffer(), {}); - await new Promise(setImmediate); - - const defaultResponse = ZCLStandardHeader.fromBuffer(capturedFrames[0]); - assert.strictEqual(defaultResponse.cmdId, 0x0b); - assert.strictEqual(defaultResponse.data[1], 0x81); + const answer = await nodeAnswerTo( + IasZoneCluster.ID, + 0x00, // zoneStatusChangeNotification + Buffer.from([0x01, 0x00, 0x00, 0x01, 0x00, 0x00]), + [IasZoneCluster.ID], + ); + assert.deepStrictEqual(answer, [0x00, 0x81]); }); it('should send UNSUPPORTED_CLUSTER (0xc3) for a server-to-client frame on a cluster the endpoint lacks', async function () { - const { deviceNode, capturedFrames } = createSpyNodePair(); - await deviceNode.handleFrame(1, LevelControlCluster.ID, makeToClientFrame(0x00), {}); - assert.strictEqual(ZCLStandardHeader.fromBuffer(capturedFrames[0].data).data[1], 0xc3); + const answer = await nodeAnswerTo(LevelControlCluster.ID, 0x00, Buffer.alloc(0)); + assert.deepStrictEqual(answer, [0x00, 0xc3]); }); it('should send UNSUPPORTED_CLUSTER (0xc3) when the endpoint has no binding for the cluster', async function () { - const { controllerNode, capturedFrames } = createSpyNodePair(); + const { zclNode, frames } = onOffDevice(); - await assert.rejects(() => controllerNode.endpoints[1].clusters.onOff.setOn(), { + await assert.rejects(zclNode.endpoints[1].clusters.onOff.setOn(), { message: 'UNSUPPORTED_CLUSTER', }); - const defaultResponseFrame = ZCLStandardHeader.fromBuffer(capturedFrames[0].data); - assert.strictEqual(defaultResponseFrame.cmdId, 0x0b); - assert.strictEqual(defaultResponseFrame.data[1], 0xc3); - }); - - it('should propagate different ZCLError status codes', async function () { - const statuses = ['NOT_AUTHORIZED', 'INVALID_VALUE', 'INSUFFICIENT_SPACE']; - - for (const status of statuses) { - const node = createMockNode({ - loopback: true, - endpoints: [ - { - endpointId: 1, - inputClusters: [OnOffCluster.ID], - }, - ], - }); - - node.endpoints[1].bind( - 'onOff', - new (class extends BoundCluster { - async setOn() { - throw new ZCLError(status); - } - })(), - ); - - await assert.rejects( - () => node.endpoints[1].clusters.onOff.setOn(), - (err) => { - assert.strictEqual(err.message, status); - return true; - }, - ); - } + assert.deepStrictEqual(deviceDefaultResponse(frames), [SET_ON_CMD_ID, 0xc3]); }); }); diff --git a/test/util/index.js b/test/util/index.js index 9783fe95..63ce7ed0 100644 --- a/test/util/index.js +++ b/test/util/index.js @@ -1,24 +1,3 @@ 'use strict'; -let { debug } = require('../../lib/util'); - -debug = debug.extend('test'); - -const { - createMockNode, - createConnectedNodePair, - createBoundClusterWithAttributes, - MOCK_DEVICES, -} = require('./mockNode'); -const { createVirtualDevice, HOMEY_MAX_ZCL_BYTES } = require('./virtualDevice'); - -module.exports = { - debug, - // Mock node utilities - createMockNode, - createConnectedNodePair, - createBoundClusterWithAttributes, - MOCK_DEVICES, - createVirtualDevice, - HOMEY_MAX_ZCL_BYTES, -}; +module.exports = require('./virtualDevice'); diff --git a/test/util/mockNode.js b/test/util/mockNode.js deleted file mode 100644 index 59e5f47c..00000000 --- a/test/util/mockNode.js +++ /dev/null @@ -1,324 +0,0 @@ -'use strict'; - -const Node = require('../../lib/Node'); -const BoundCluster = require('../../lib/BoundCluster'); - -// Load clusters and get their IDs -const BasicCluster = require('../../lib/clusters/basic'); -const PowerConfigurationCluster = require('../../lib/clusters/powerConfiguration'); -const IASZoneCluster = require('../../lib/clusters/iasZone'); -const TemperatureMeasurementCluster = require('../../lib/clusters/temperatureMeasurement'); -const RelativeHumidityCluster = require('../../lib/clusters/relativeHumidity'); -const OnOffCluster = require('../../lib/clusters/onOff'); -const MeteringCluster = require('../../lib/clusters/metering'); -const ElectricalMeasurementCluster = require('../../lib/clusters/electricalMeasurement'); - -/** - * Creates a BoundCluster with getter properties for each attribute. - * - * @param {Object} attributes - Attribute name/value pairs - * @returns {BoundCluster} Configured BoundCluster - */ -function createBoundClusterWithAttributes(attributes) { - const ClusterClass = class extends BoundCluster { - constructor() { - super(); - // Store mutable attributes - this._attributes = { ...attributes }; - } - }; - - // Define getters/setters for each attribute - Object.keys(attributes).forEach((attrName) => { - Object.defineProperty(ClusterClass.prototype, attrName, { - get() { - return this._attributes[attrName]; - }, - set(value) { - this._attributes[attrName] = value; - }, - enumerable: true, - configurable: true, - }); - }); - - return new ClusterClass(); -} - -/** - * Creates a mock device Node with configurable cluster attributes. - * - * @example - * const mockMotionSensor = createMockDevice({ - * endpoints: [{ - * endpointId: 1, - * inputClusters: [0x0000, 0x0001, 0x0500], - * clusters: { - * iasZone: { - * zoneType: 0x000D, // Motion sensor - * zoneState: 1, // Enrolled - * zoneStatus: 0, - * }, - * powerConfiguration: { - * batteryPercentageRemaining: 180, // 90% - * }, - * }, - * }], - * }); - * - * @param {Object} config - Device configuration - * @param {Array} config.endpoints - Endpoint configurations - * @param {number} config.endpoints[].endpointId - Endpoint ID - * @param {number[]} config.endpoints[].inputClusters - Input cluster IDs - * @param {number[]} [config.endpoints[].outputClusters] - Output cluster IDs - * @param {Object} [config.endpoints[].clusters] - Cluster attribute values keyed by cluster name - * @param {boolean} [config.loopback] - If true, frames are looped back to the node's handleFrame - * @returns {Node} Configured mock Node - */ -function createMockNode(config) { - const endpointDescriptors = config.endpoints.map((ep) => ({ - endpointId: ep.endpointId, - inputClusters: ep.inputClusters || [], - outputClusters: ep.outputClusters || [], - })); - - // Create node reference first so sendFrame can reference it - let node; - const mockNode = { - sendFrame: config.loopback ? (...args) => node.handleFrame(...args) : () => Promise.resolve(), - endpointDescriptors, - }; - - node = new Node(mockNode); - - // Bind clusters with preset attribute values - config.endpoints.forEach((ep) => { - if (!ep.clusters) return; - - Object.entries(ep.clusters).forEach(([clusterName, attributes]) => { - const boundCluster = createBoundClusterWithAttributes(attributes); - node.endpoints[ep.endpointId].bind(clusterName, boundCluster); - }); - }); - - return node; -} - -/** - * Creates a pair of connected mock nodes that can send frames to each other. - * - * @param {Object} config1 - Configuration for first node - * @param {Object} config2 - Configuration for second node - * @returns {[Node, Node]} Tuple of connected nodes - */ -function createConnectedNodePair(config1, config2) { - const endpointDescriptors1 = config1.endpoints.map((ep) => ({ - endpointId: ep.endpointId, - inputClusters: ep.inputClusters || [], - outputClusters: ep.outputClusters || [], - })); - - const endpointDescriptors2 = config2.endpoints.map((ep) => ({ - endpointId: ep.endpointId, - inputClusters: ep.inputClusters || [], - outputClusters: ep.outputClusters || [], - })); - - let node1; - let node2; - - const mockNode1 = { - sendFrame: (...args) => node2.handleFrame(...args), - endpointDescriptors: endpointDescriptors1, - }; - - const mockNode2 = { - sendFrame: (...args) => node1.handleFrame(...args), - endpointDescriptors: endpointDescriptors2, - }; - - node1 = new Node(mockNode1); - node2 = new Node(mockNode2); - - // Bind clusters for node1 - config1.endpoints.forEach((ep) => { - if (!ep.clusters) return; - Object.entries(ep.clusters).forEach(([clusterName, attributes]) => { - const boundCluster = createBoundClusterWithAttributes(attributes); - node1.endpoints[ep.endpointId].bind(clusterName, boundCluster); - }); - }); - - // Bind clusters for node2 - config2.endpoints.forEach((ep) => { - if (!ep.clusters) return; - Object.entries(ep.clusters).forEach(([clusterName, attributes]) => { - const boundCluster = createBoundClusterWithAttributes(attributes); - node2.endpoints[ep.endpointId].bind(clusterName, boundCluster); - }); - }); - - return [node1, node2]; -} - -/** - * Preset device configurations for common device types. - */ -const MOCK_DEVICES = { - /** - * IAS Zone Motion Sensor - */ - motionSensor: (overrides = {}) => - createMockNode({ - endpoints: [ - { - endpointId: 1, - inputClusters: [BasicCluster.ID, PowerConfigurationCluster.ID, IASZoneCluster.ID], - clusters: { - basic: { - zclVersion: 3, - manufacturerName: 'MockDevice', - modelId: 'MockMotionSensor', - ...overrides.basic, - }, - powerConfiguration: { - batteryPercentageRemaining: 200, // 100% - ...overrides.powerConfiguration, - }, - iasZone: { - zoneState: 1, // Enrolled - zoneType: 0x000d, // Motion sensor - zoneStatus: 0, - iasCIEAddress: '0x0000000000000000', - zoneId: 1, - ...overrides.iasZone, - }, - }, - }, - ], - }), - - /** - * IAS Zone Contact Sensor (door/window) - */ - contactSensor: (overrides = {}) => - createMockNode({ - endpoints: [ - { - endpointId: 1, - inputClusters: [BasicCluster.ID, PowerConfigurationCluster.ID, IASZoneCluster.ID], - clusters: { - basic: { - zclVersion: 3, - manufacturerName: 'MockDevice', - modelId: 'MockContactSensor', - ...overrides.basic, - }, - powerConfiguration: { - batteryPercentageRemaining: 200, - ...overrides.powerConfiguration, - }, - iasZone: { - zoneState: 1, - zoneType: 0x0015, // Contact switch - zoneStatus: 0, - iasCIEAddress: '0x0000000000000000', - zoneId: 1, - ...overrides.iasZone, - }, - }, - }, - ], - }), - - /** - * Temperature + Humidity Sensor - */ - tempHumiditySensor: (overrides = {}) => - createMockNode({ - endpoints: [ - { - endpointId: 1, - inputClusters: [ - BasicCluster.ID, - PowerConfigurationCluster.ID, - TemperatureMeasurementCluster.ID, - RelativeHumidityCluster.ID, - ], - clusters: { - basic: { - zclVersion: 3, - manufacturerName: 'MockDevice', - modelId: 'MockTempHumidity', - ...overrides.basic, - }, - powerConfiguration: { - batteryPercentageRemaining: 200, - ...overrides.powerConfiguration, - }, - temperatureMeasurement: { - measuredValue: 2150, // 21.50°C - minMeasuredValue: -4000, - maxMeasuredValue: 8500, - ...overrides.temperatureMeasurement, - }, - relativeHumidity: { - measuredValue: 6500, // 65.00% - minMeasuredValue: 0, - maxMeasuredValue: 10000, - ...overrides.relativeHumidity, - }, - }, - }, - ], - }), - - /** - * Smart Plug with Power Metering - */ - smartPlug: (overrides = {}) => - createMockNode({ - endpoints: [ - { - endpointId: 1, - inputClusters: [ - BasicCluster.ID, - OnOffCluster.ID, - MeteringCluster.ID, - ElectricalMeasurementCluster.ID, - ], - clusters: { - basic: { - zclVersion: 3, - manufacturerName: 'MockDevice', - modelId: 'MockSmartPlug', - ...overrides.basic, - }, - onOff: { - onOff: false, - ...overrides.onOff, - }, - metering: { - currentSummationDelivered: 12345678, - multiplier: 1, - divisor: 1000, - ...overrides.metering, - }, - electricalMeasurement: { - activePower: 1500, // 150.0W - rmsVoltage: 2300, // 230.0V - rmsCurrent: 652, // 0.652A - ...overrides.electricalMeasurement, - }, - }, - }, - ], - }), -}; - -module.exports = { - createMockNode, - createConnectedNodePair, - createBoundClusterWithAttributes, - MOCK_DEVICES, -}; diff --git a/test/util/virtualDevice.js b/test/util/virtualDevice.js index b2ac4a43..32ec4c91 100644 --- a/test/util/virtualDevice.js +++ b/test/util/virtualDevice.js @@ -3,11 +3,39 @@ const Node = require('../../lib/Node'); const Endpoint = require('../../lib/Endpoint'); const BoundCluster = require('../../lib/BoundCluster'); -const { createBoundClusterWithAttributes } = require('./mockNode'); // Homey's Zigbee stack rejects a longer ZCL frame (MAX_ZCL_BYTES in @athombv/z3gateway). const HOMEY_MAX_ZCL_BYTES = 82; +const MAX_FRAMES = 1000; + +// A malformed frame is still delivered as it is, it just has no parsed form in the log. +function parse(raw) { + try { + return Endpoint.parseFrame(raw); + } catch { + return undefined; + } +} + +/** A BoundCluster serving `attributes`: reads return them, writes replace them. */ +function boundClusterWithAttributes(attributes) { + const values = { ...attributes }; + const bound = new BoundCluster(); + // Accessors, not plain fields: BoundCluster.writeAttributes treats a field without a setter as + // read-only. + for (const name of Object.keys(values)) { + Object.defineProperty(bound, name, { + get: () => values[name], + set: (value) => { + values[name] = value; + }, + enumerable: true, + }); + } + return bound; +} + /** * Creates a virtual device and the ZCLNode a driver would get for it, connected by an in-memory * link that records every frame and lets a test drop, rewrite or inject frames. @@ -36,10 +64,10 @@ const HOMEY_MAX_ZCL_BYTES = 82; * intercept: function(function(Object): (undefined|null|Buffer|Buffer[])): void, * settle: function(): Promise, * }} - * `zclNode` is the node under test, as a driver gets it in `onNodeInit`. `virtualDevice` sends commands - * through `virtualDevice.endpoints[n].clusters` and any other frame, such as an attribute report, with - * `virtualDevice.sendFrame(endpointId, clusterId, buffer)`. An interceptor sees each frame before - * delivery and returns `undefined` to pass it, `null` to drop it, or one or more buffers to + * `zclNode` is the node under test, as a driver gets it in `onNodeInit`. `virtualDevice` sends + * commands through `virtualDevice.endpoints[n].clusters` and any other frame, such as an attribute + * report, with `virtualDevice.sendFrame(endpointId, clusterId, buffer, meta)`, where `meta` is the + * frame meta the stack would pass along. An interceptor sees each frame before delivery and returns `undefined` to pass it, `null` to drop it, or one or more buffers to * deliver instead; throwing fails the sender's `sendFrame` like a stack error. `settle` resolves * once every frame in flight, and the frames sent in reply, has been handled. */ @@ -55,7 +83,11 @@ function createVirtualDevice({ endpoints, maxFrameSize = HOMEY_MAX_ZCL_BYTES }) inputClusters: ep.inputClusters || [], outputClusters: ep.outputClusters || [], })), - async sendFrame(endpointId, clusterId, data) { + async sendFrame(endpointId, clusterId, data, meta) { + // Breaks an endless request/response loop between the nodes, so the test fails, not hangs. + if (frames.length >= MAX_FRAMES) { + throw new Error(`More than ${MAX_FRAMES} frames sent, are the nodes stuck in a loop?`); + } if (from === 'zclNode' && data.length > maxFrameSize) { throw new Error(`ZCL frame too long, got ${data.length} bytes, max: ${maxFrameSize}.`); } @@ -63,13 +95,7 @@ function createVirtualDevice({ endpoints, maxFrameSize = HOMEY_MAX_ZCL_BYTES }) let raws = [data]; for (const interceptor of interceptors) { raws = raws.flatMap((raw) => { - const result = interceptor({ - from, - endpointId, - clusterId, - frame: Endpoint.parseFrame(raw), - raw, - }); + const result = interceptor({ from, endpointId, clusterId, frame: parse(raw), raw }); if (result === undefined) return [raw]; if (result === null) return []; return result; @@ -77,13 +103,10 @@ function createVirtualDevice({ endpoints, maxFrameSize = HOMEY_MAX_ZCL_BYTES }) } for (const raw of raws) { - const record = { from, endpointId, clusterId, raw }; + const record = { from, endpointId, clusterId, frame: parse(raw), raw }; frames.push(record); - const delivery = (async () => { - // Parsed here, not up front: a malformed frame must reach the receiver as it is. - record.frame = Endpoint.parseFrame(raw); - await nodes[to].handleFrame(endpointId, clusterId, raw); - })() + const delivery = nodes[to] + .handleFrame(endpointId, clusterId, raw, meta) .catch((err) => { record.error = err; }) @@ -107,7 +130,7 @@ function createVirtualDevice({ endpoints, maxFrameSize = HOMEY_MAX_ZCL_BYTES }) Object.entries(ep.clusters || {}).forEach(([clusterName, cluster]) => { nodes.virtualDevice.endpoints[ep.endpointId].bind( clusterName, - cluster instanceof BoundCluster ? cluster : createBoundClusterWithAttributes(cluster), + cluster instanceof BoundCluster ? cluster : boundClusterWithAttributes(cluster), ); }); }); diff --git a/test/virtualDevice.js b/test/virtualDevice.js index 1c6d86f4..85b81ae4 100644 --- a/test/virtualDevice.js +++ b/test/virtualDevice.js @@ -57,6 +57,18 @@ describe('Virtual device', function () { ); }); + it('lets attribute values be written', async function () { + const { zclNode, virtualDevice } = createVirtualDevice({ + endpoints: [ + { endpointId: 1, inputClusters: [OnOffCluster.ID], clusters: { onOff: { onTime: 1 } } }, + ], + }); + + await zclNode.endpoints[1].clusters.onOff.writeAttributes({ onTime: 7 }); + + assert.strictEqual(virtualDevice.endpoints[1].bindings.onOff.onTime, 7); + }); + it('times out when an interceptor drops the response', async function (t) { t.mock.timers.enable({ apis: ['setTimeout'] }); const { zclNode, intercept, settle } = createVirtualDevice({ From f611b41bbdc237b2812217d9b4dda83686430c3d Mon Sep 17 00:00:00 2001 From: Robin Bolscher Date: Tue, 27 Oct 2026 12:48:08 +0100 Subject: [PATCH 3/8] docs: Document the virtual device test harness docs/testing.md explains both sides of createVirtualDevice, its options and return values, recipes for misbehaving devices, and the rules that keep a test able to fail. AGENTS.md and the README point to it. --- AGENTS.md | 30 ++++----- README.md | 2 +- docs/testing.md | 158 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 18 deletions(-) create mode 100644 docs/testing.md diff --git a/AGENTS.md b/AGENTS.md index c972bde5..a60d1271 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,20 +51,16 @@ Refer to a cluster id as `OnOffCluster.ID`, never as `6` or `0x0006`. ## Tests -Tests run on `node:test`, so `describe`, `it` and `mock` are imported, not globals. A callback-style -test takes the test context first: `it('...', function (t, done) { ... })`. Written as -`function (done)` node:test sees a synchronous test, passes it immediately and never runs the -assertions. - -Build mock nodes with the helpers in `test/util/index.js` instead of by hand: - -- `createMockNode({ loopback: true, ... })` sends a command straight back into the same node's - `BoundCluster`, which covers most cases. See `test/onOff.js`. -- `createConnectedNodePair()` wires two nodes together when sender and receiver must differ. See - `test/testNode.js`. -- `MOCK_DEVICES` holds preset sensors. See `test/iasZone.js`. -- `createVirtualDevice()` for a misbehaving device: a separate device node behind a link that - records every frame, enforces Homey's 82-byte frame limit and lets an interceptor drop, rewrite - or duplicate frames. Device-initiated frames such as attribute reports go out with - `virtualDevice.sendFrame()`. See `test/virtualDevice.js`, which also keeps known bugs as `todo` - tests; a fix removes the `todo`. +Tests run on `node:test`, so `describe`, `it` and `mock` are imported, not globals. + +Every test that needs a node uses `createVirtualDevice()` from `test/util`: the code under test +(`zclNode`) and a scripted device (`virtualDevice`) on two nodes, with an in-memory link that logs +every frame (`frames`), can drop or rewrite them (`intercept`) and waits for them (`settle`). Read +[docs/testing.md](docs/testing.md) before writing a test. The rules that matter most: + +- Never assert inside a `BoundCluster` or cluster handler: the endpoint turns the thrown assertion + into an error Default Response. Store what the handler received, assert after the `await`. +- No `done` callbacks; `await settle()` before asserting that something did not happen. +- Assert on the bytes in `frames` when the encoding is the point, not on a round trip through the + same definition. +- A known bug is a test with `{ todo: '...' }`; check it fails on the bug, not on the test. diff --git a/README.md b/README.md index 87a743f3..7f5b45a5 100644 --- a/README.md +++ b/README.md @@ -340,7 +340,7 @@ The workflow commits updated types back to that branch if changes are detected. Great if you'd like to contribute to this project, a few things to take note of before submitting a PR: - This project enforces ESLint and Prettier: check with `npm run lint` and `npm run format:check`, and fix formatting with `npm run format:write`. -- Tests use the built-in node:test runner, see the [test](https://github.com/athombv/node-zigbee-clusters/blob/production/test) directory. +- Tests use the built-in node:test runner and one virtual device harness, see [Writing tests](docs/testing.md). - Adding or updating a cluster definition follows its own conventions, see [Adding or updating a cluster definition](docs/adding-clusters.md). - Pull request titles follow [Conventional Commits](https://www.conventionalcommits.org/), for example `fix(onOff): Handle a missing attribute`. The title becomes a line in the release notes, so write it for someone using the library. See [Release notes](#release-notes). - [GitHub Action workflows](https://github.com/athombv/node-zigbee-clusters/blob/production/.github/workflows) run ESLint, Prettier and the tests, and publish releases. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 00000000..a2acbd8d --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,158 @@ +# Writing tests + +Every test that needs a node uses one harness, `createVirtualDevice()` from `test/util`. It puts the +code under test and a scripted Zigbee device on two separate nodes, connected by an in-memory link +that records every frame. No Homey and no radio are involved, so the whole suite runs in well under +a second. See [Contributing](../README.md#contributing) for the checks every pull request has to +pass. + +## The two sides + +``` +zclNode <-- in-memory link: logs, intercepts, 82-byte limit --> virtualDevice +(the code under test, (the device it talks to, + as a driver gets it in onNodeInit) scripted by the test) +``` + +- `zclNode` is a `ZCLNode`, exactly what a Homey driver gets. Its `endpoints[n].clusters` are the + client side that reads attributes and sends commands to the device; its `endpoints[n].bind()` + serves what the device sends to Homey, such as a remote's commands or OTA requests. +- `virtualDevice` is a second `ZCLNode` that plays the device. Its endpoints serve the device's + input clusters with `BoundCluster`s and, for its output clusters, have `clusters` to send commands + to `zclNode`. + +Both sides run the library's real frame parsing, encoding, response matching and default response +rules. Only the stack below `ZCLNode.sendFrame` is replaced. + +## Quick start + +```js +const { createVirtualDevice } = require('./util'); + +it('reads the on/off state', async function () { + const { zclNode } = createVirtualDevice({ + endpoints: [ + { endpointId: 1, inputClusters: [OnOffCluster.ID], clusters: { onOff: { onOff: true } } }, + ], + }); + + const result = await zclNode.endpoints[1].clusters.onOff.readAttributes(['onOff']); + + assert.deepStrictEqual(result, { onOff: true }); +}); +``` + +## Reference + +`createVirtualDevice({ endpoints, maxFrameSize })` describes the device as its descriptors would: + +| Field | Meaning | +| ---------------------------- | -------------------------------------------------------------------------- | +| `endpoints[].endpointId` | Endpoint id, used on both sides. | +| `endpoints[].inputClusters` | Server clusters of the device. `zclNode` gets a client `Cluster` for each. | +| `endpoints[].outputClusters` | Client clusters of the device. `virtualDevice` gets a `Cluster` for each. | +| `endpoints[].clusters` | Per cluster name, attribute values or a `BoundCluster` instance. | +| `maxFrameSize` | Longest frame `zclNode` may send, default 82 bytes as Homey's stack. | + +Attribute values become a `BoundCluster` that answers reads and accepts writes. Pass your own +`BoundCluster` subclass to script commands or misbehave. A cluster can also be bound later with +`virtualDevice.endpoints[n].bind()`. + +It returns: + +- `zclNode`: the node under test. +- `virtualDevice`: the device node. `virtualDevice.sendFrame(endpointId, clusterId, buffer, meta)` + sends any raw frame to `zclNode`; `meta` is what the stack would pass along, such as + `{ groupId }` or `{ broadcast: true }`. +- `frames`: every delivered frame, in order, as `{ from, endpointId, clusterId, frame, raw, error }`. + `from` is `'zclNode'` or `'virtualDevice'`, `frame` the parsed header (`undefined` when + malformed), and `error` is set when the receiver threw. +- `intercept(fn)`: `fn(record)` sees each frame before delivery and returns `undefined` to pass it, + `null` to drop it, or a buffer or array of buffers to deliver instead. Throwing makes the sender's + `sendFrame` reject, as a stack error does. +- `settle()`: resolves once every frame in flight, and each frame sent in reply, has been handled. + +The link also fails a send once 1000 frames have gone over it, so two nodes stuck answering each +other fail the test instead of hanging it. + +## Recipes + +**Assert on the wire, not on a round trip.** Encoding and decoding with the same definition passes +even when that definition is wrong. When the bytes matter, compare `frames[i].raw` or +`frames[i].frame` against the specification, as `test/colorControl.js` does. + +**A device that misbehaves.** Override the handler on a `BoundCluster`, for example a read response +with ids that were not asked for: + +```js +const onOff = new (class extends BoundCluster { + async readAttributes() { + const buf = Buffer.alloc(255); + const records = [{ id: ON_TIME, status: 'SUCCESS', value: 5 }]; + const len = OnOffCluster.attributeArrayStatusDataType.toBuffer(buf, records, 0); + return { attributes: buf.subarray(0, len) }; + } +})(); +const { zclNode } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters: [OnOffCluster.ID], clusters: { onOff } }], +}); +``` + +**A device that never answers.** Drop its frames and advance the response timeout with mock timers: + +```js +t.mock.timers.enable({ apis: ['setTimeout'] }); +intercept(({ from }) => (from === 'virtualDevice' ? null : undefined)); +const read = zclNode.endpoints[1].clusters.onOff.readAttributes(['onOff']); +await settle(); +t.mock.timers.tick(10_000); +await assert.rejects(read, /Timeout/); +``` + +**Duplicate, colliding or rewritten frames.** Return more than one buffer from `intercept`. To have +the device send an unrelated frame with the pending TSN right before its reply: + +```js +intercept(({ from, frame, raw }) => + from === 'virtualDevice' ? [reportWithTsn(frame.trxSequenceNumber), raw] : undefined, +); +``` + +**Something the device starts, such as a report or a notification.** Build the frame with +`ZCLStandardHeader` (`frameControl.directionToClient = true`), send it with +`virtualDevice.sendFrame()`, then `await settle()` before asserting. See `test/attributeReport.js`. + +**Homey serving the device.** For traffic the device starts, such as OTA requests or a remote's +commands, give the device the cluster as an output cluster, bind the `BoundCluster` on `zclNode` +and send from `virtualDevice.endpoints[n].clusters`. See `test/ota.js`. + +**A known bug.** Write the test for the correct behavior and mark it +`{ todo: 'what is wrong, or the fixing PR' }`. It runs and reports, but does not fail the suite. The +fix removes the `todo`. Check in the output that a todo test fails on the bug itself and not on a +mistake in the test, because nothing else will tell you. See `test/virtualDevice.js`. + +## Rules + +- **Never assert inside a handler.** An error thrown in a `BoundCluster` or cluster handler is + caught by the endpoint and turned into an error Default Response, so the assertion message is + lost or the test passes anyway. Store what the handler received and assert after the `await`. +- **No `done` callbacks.** Await the command, or `settle()`, then assert. A `done` that is never + called hangs the test instead of failing it. +- **`await settle()` before asserting that something did not happen**, and before asserting on + anything a device-initiated frame triggers. Awaiting a command only waits for its own response. +- **Fake time, never wait.** Use `t.mock.timers` for response timeouts, which default to 10 seconds. +- **One device per test.** Create it inside the `it`, so no test depends on what an earlier one + bound. +- **Assert specific errors.** `assert.rejects(promise, { message: 'UNSUPPORTED_CLUSTER' })`, not + just that something threw. +- **Prove the failure path.** Break the library code the test is about and check the test fails. + +## Known gaps + +- The device has no typed way to send reports or server-to-client commands on its input clusters, + so those frames are built by hand. +- Interceptors run synchronously before delivery, so "deliver, then have `sendFrame` reject" or "resolve + `sendFrame` late" need a stub on top of the harness, see `test/testClusterResponseTimeout.js`. +- Dropped frames do not appear in `frames`. +- `test/endpointStandalone.js` builds an `Endpoint` without a node on purpose and does not use the + harness. From 83f7424da8c9c092f2daaf7412607a415973028a Mon Sep 17 00:00:00 2001 From: Robin Bolscher Date: Tue, 27 Oct 2026 12:55:19 +0100 Subject: [PATCH 4/8] test: Turn the Xiaomi lifeline script into a real test test/manual/xiaomiHacks.js registered a Basic cluster that declares its own data type for Xiaomi's attribute 0xff01, but never decoded a frame, so it checked nothing. The new test has the virtual device send a report shaped like the ones Xiaomi devices send and asserts the decoded records. It fails when the library decodes with the type on the wire instead of the declared one, which is what such a custom cluster relies on. --- AGENTS.md | 3 +- test/manual/xiaomiHacks.js | 133 ----------------------------------- test/xiaomiLifeline.js | 139 +++++++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 134 deletions(-) delete mode 100644 test/manual/xiaomiHacks.js create mode 100644 test/xiaomiLifeline.js diff --git a/AGENTS.md b/AGENTS.md index a60d1271..eb8d2de6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,8 @@ npm run generate-types # regenerate index.d.ts after changing cluster definitio npm run build # JSDoc site into build/, open build/index.html in a browser ``` -`npm test` globs `test/*.js` only, so nothing under `test/manual/` runs with it. +`npm test` globs `test/*.js` only, so a test file must sit directly in `test/`; `test/util/` holds +the harness. `index.d.ts` is generated from the cluster definitions and committed. Never edit it by hand; run `npm run generate-types`, which CI does too. diff --git a/test/manual/xiaomiHacks.js b/test/manual/xiaomiHacks.js deleted file mode 100644 index 90ef1a82..00000000 --- a/test/manual/xiaomiHacks.js +++ /dev/null @@ -1,133 +0,0 @@ -'use strict'; - -const { ZCLDataTypes, ZCLDataType } = require('../../lib/zclTypes'); -const Cluster = require('../../lib/Cluster'); -const BasicCluster = require('../../lib/clusters/basic'); - -const attributes = { - 1: { name: 'batteryVoltage' }, // type: ZCLDataTypes.uint16 - 3: { name: 'cpuTemperature' }, // type: ZCLDataTypes.int8 - 4: { name: 'unk_4' }, // type: ZCLDataTypes.uint16 - 5: { name: 'unk_5' }, // type: ZCLDataTypes.uint16 - 6: { name: 'txCount' }, // type: ZCLDataTypes.uint40 - 10: { name: 'unk_10' }, // type: ZCLDataTypes.uint16 - 11: { name: 'illuminance' }, - 100: { name: 'state0' }, - 101: { name: 'state1' }, - 102: { name: 'state2' }, - 149: { name: 'consumption' }, - 150: { name: 'voltage' }, - 151: { name: 'unk_151' }, - 152: { name: 'power' }, - 153: { name: 'unk_153' }, - 154: { name: 'unk_154' }, -}; - -// custom data type parser with dynamic length with minimal 3 bytes, and no declared ZCL id. -const XiaomiLifelineDataRecord = new ZCLDataType( - NaN, - 'XiaomiLifelineDataRecord', - -3, - function (_buf, _v, _i) { - throw new Error('generating xiaomi structs is not implemented'); - }, - function (buf, i, returnLength) { - i = i || 0; - const startByte = i; - const res = {}; - res.id = ZCLDataTypes.uint8.fromBuffer(buf, i); - i += ZCLDataTypes.uint8.length; - - const dataTypeId = ZCLDataTypes.uint8.fromBuffer(buf, i); - i += ZCLDataTypes.uint8.length; - - const DataType = - (attributes[res.id] && attributes[res.id].type) || - Object.values(ZCLDataTypes).find((type) => type && type.id === dataTypeId); - if (!DataType) throw new TypeError(`Invalid Type for Attribute: ${res.id}`); - - res.name = (attributes[res.id] && attributes[res.id].name) || `unknown_attr_${res.id}`; - - const entry = DataType.fromBuffer(buf, i, true); - if (DataType.length > 0) { - i += DataType.length; - res.value = entry; - } else { - res.value = entry.result; - i += entry.length; - } - if (returnLength) { - return { result: res, length: i - startByte }; - } - return res; - }, -); - -const XiaomiLifelineDataRecordArray = new ZCLDataType( - NaN, - 'XiaomiLifelineDataRecordArray', - -1, - function (_buf, _v, _i) { - throw new Error('not_supported'); - }, - function (buf, i, returnLength) { - let { result, length } = ZCLDataTypes.buffer8.fromBuffer(buf, i, true); - result = ZCLDataTypes.Array0(XiaomiLifelineDataRecord).fromBuffer(result, 0); - result = result.reduce((r, { name, value }) => Object.assign(r, { [name]: value }), {}); - if (returnLength) { - return { result, length }; - } - return result; - }, -); - -class XiaomiHackedBasicCluster extends BasicCluster { - static get ATTRIBUTES() { - return { - ...super.ATTRIBUTES, - xiaomiLifeline: { id: 0xff01, type: XiaomiLifelineDataRecordArray }, - }; - } -} - -// Replace basic cluster with our patched version -Cluster.addCluster(XiaomiHackedBasicCluster); - -// //Demo non-meshdriver zigbee API: -// const OnOffCluster = require('homey-zcl/zcl/onOff'); - -// node.endpoints[1].basic.on('attr.xiaomiLifeline', (attributes, rawFrame) => { -// console.log('xiaomi custom state:', attributes.state1); -// }) - -// const {modelId, dateCode} = await node.endpoints[1].basic.readAttributes([ -// 'modelId', 'dateCode' -// ]); -// node.endpoint[1].clusters.basic.configureReporting({ -// powerSource: {minInterval: 1234, maxInterval: 4321, minChange: 10}}); //enables reporting - -// TODO: invert group commands -// class LocalOnOffCluster { -// async on() { -// //binding said on, update attribute -// this.onOff = true; -// } - -// async off() { -// //binding said off -// this.onOff = false; -// } - -// async toggle() { -// //binding said toggle -// this.onOff = !this.onOff; -// } -// } - -// node.endpoint[1].bind('onOff', new LocalOnOffCluster()); // binds the device+endpoint+cluster -// to us -// node.endpoint[1].unbind('onOff'); //removes a previous binding to us -// node.endpoint[1].bindings.onOff.onOff // returns boolean -// const groupsResponse = await node.endpoints[1].touchlink.getGroups(); -// const {groupId} = groupsResponse.groups[0]; -// diff --git a/test/xiaomiLifeline.js b/test/xiaomiLifeline.js new file mode 100644 index 00000000..3f61f33d --- /dev/null +++ b/test/xiaomiLifeline.js @@ -0,0 +1,139 @@ +'use strict'; + +// Xiaomi and Aqara devices report their state as Basic attribute 0xff01, typed on the wire as a +// character string (0x42) but holding a list of id/type/value records. A cluster subclass can +// declare its own data type for that attribute, and a report must then be decoded with the +// declared type rather than the one on the wire. + +const { describe, it, mock } = require('node:test'); +const assert = require('node:assert'); + +const { ZCLDataTypes, ZCLDataType } = require('../lib/zclTypes'); +const Cluster = require('../lib/Cluster'); +const BasicCluster = require('../lib/clusters/basic'); +const { createVirtualDevice } = require('./util'); + +const LIFELINE_RECORDS = { + 1: 'batteryVoltage', + 3: 'cpuTemperature', + 4: 'unk_4', + 6: 'txCount', + 100: 'state0', +}; + +// One record: id (uint8), data type (uint8), value of that type. At least 3 bytes, no ZCL id. +const XiaomiLifelineDataRecord = new ZCLDataType( + NaN, + 'XiaomiLifelineDataRecord', + -3, + () => { + throw new Error('not_supported'); + }, + (buf, i = 0, returnLength) => { + const start = i; + const id = ZCLDataTypes.uint8.fromBuffer(buf, i); + const dataTypeId = ZCLDataTypes.uint8.fromBuffer(buf, i + 1); + i += 2; + + const DataType = Object.values(ZCLDataTypes).find((type) => type && type.id === dataTypeId); + if (!DataType) throw new TypeError(`Invalid Type for Attribute: ${id}`); + + const entry = DataType.fromBuffer(buf, i, true); + let value; + if (DataType.length > 0) { + value = entry; + i += DataType.length; + } else { + value = entry.result; + i += entry.length; + } + + const result = { name: LIFELINE_RECORDS[id] || `unknown_attr_${id}`, value }; + return returnLength ? { result, length: i - start } : result; + }, +); + +// The records, wrapped in the length-prefixed string the device puts on the wire. +const XiaomiLifelineDataRecordArray = new ZCLDataType( + NaN, + 'XiaomiLifelineDataRecordArray', + -1, + () => { + throw new Error('not_supported'); + }, + (buf, i, returnLength) => { + const { result: records, length } = ZCLDataTypes.buffer8.fromBuffer(buf, i, true); + const result = Object.fromEntries( + ZCLDataTypes.Array0(XiaomiLifelineDataRecord) + .fromBuffer(records, 0) + .map(({ name, value }) => [name, value]), + ); + return returnLength ? { result, length } : result; + }, +); + +class XiaomiBasicCluster extends BasicCluster { + static get ATTRIBUTES() { + return { + ...super.ATTRIBUTES, + xiaomiLifeline: { id: 0xff01, type: XiaomiLifelineDataRecordArray }, + }; + } +} + +/** A string attribute: data type 0x42, length byte, then the bytes. */ +const string = (bytes) => Buffer.from([0x42, bytes.length, ...bytes]); + +// One lifeline record per line: id, data type, little-endian value +const LIFELINE = Buffer.from([ + ...[0x01, 0x21, 0xb8, 0x0b], // batteryVoltage, uint16 3000 + ...[0x03, 0x28, 0x16], // cpuTemperature, int8 22 + ...[0x04, 0x21, 0xd2, 0x04], // unk_4, uint16 1234 + ...[0x06, 0x24, 0x70, 0x11, 0x01, 0x00, 0x00], // txCount, uint40 70000 + ...[0x08, 0x21, 0x00, 0x01], // no name in the table, uint16 256 + ...[0x64, 0x10, 0x01], // state0, bool true +]); + +// An attribute report shaped like the ones Xiaomi devices send: manufacturer specific with +// Xiaomi's code 0x115f, modelId first, then 0xff01 as a string holding the records. +const XIAOMI_REPORT = Buffer.concat([ + Buffer.from([0x1c, 0x5f, 0x11, 0x01, 0x0a]), // frame control, manufacturer, TSN, report + Buffer.from([0x05, 0x00]), // modelId + string(Buffer.from('lumi.virtual.sensor')), + Buffer.from([0x01, 0xff]), // 0xff01 + string(LIFELINE), +]); + +describe('Xiaomi lifeline attribute', function () { + it('should decode a report with the type the cluster declares, not the one on the wire', async function (t) { + Cluster.addCluster(XiaomiBasicCluster); + t.after(() => Cluster.addCluster(BasicCluster)); + + const { zclNode, virtualDevice, frames, settle } = createVirtualDevice({ + endpoints: [{ endpointId: 1, inputClusters: [BasicCluster.ID] }], + }); + const { basic } = zclNode.endpoints[1].clusters; + const onModelId = mock.fn(); + const onLifeline = mock.fn(); + basic.on('attr.modelId', onModelId); + basic.on('attr.xiaomiLifeline', onLifeline); + + await virtualDevice.sendFrame(1, BasicCluster.ID, XIAOMI_REPORT); + await settle(); + + assert.deepStrictEqual(onModelId.mock.calls[0].arguments[0], 'lumi.virtual.sensor'); + assert.deepStrictEqual(onLifeline.mock.calls[0].arguments[0], { + batteryVoltage: 3000, + cpuTemperature: 22, + unk_4: 1234, + txCount: 70000, + unknown_attr_8: 256, + state0: true, + }); + // Disable Default Response is set, so the node does not answer + assert.deepStrictEqual( + frames.map(({ from }) => from), + ['virtualDevice'], + ); + }); +}); From 6235bb6191d7167faa72e993b10d4caf7019f940 Mon Sep 17 00:00:00 2001 From: Robin Bolscher Date: Tue, 27 Oct 2026 13:15:07 +0100 Subject: [PATCH 5/8] test: Deliver harness frames after sendFrame resolves Frames were delivered while sendFrame was still running, so every reply arrived before the command armed its response timeout: setting that timeout to 0 failed one test. Frames now arrive on the next turn of the event loop, as on Homey where sendFrame resolves on the APS ACK and the response comes later. The same mutation now fails 33 tests. A loop between two handlers only debug-logged the frame cap error, so the test passed. settle() now rethrows a failed delivery, including the cap. An interceptor that returns a non-Buffer throws instead of being dropped silently. frameToClient() and createSingleClusterDevice() replace the hand-built headers and per-file single-cluster helpers. frames[].error, which the node never let through, is gone, and docs/testing.md matches the code. --- AGENTS.md | 14 +--- docs/testing.md | 41 ++++++----- test/attributeReport.js | 17 ++--- test/doorLock.js | 37 +++++----- test/iasZone.js | 11 +-- test/testClusterResponseTimeout.js | 44 ++++------- test/testMfgSpecificCommands.js | 9 +-- test/testNode.js | 42 +++++------ test/testZCLError.js | 29 ++------ test/util/virtualDevice.js | 113 +++++++++++++++-------------- test/virtualDevice.js | 80 ++++++++++---------- test/xiaomiLifeline.js | 6 +- 12 files changed, 206 insertions(+), 237 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eb8d2de6..a9612f77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,14 +54,6 @@ Refer to a cluster id as `OnOffCluster.ID`, never as `6` or `0x0006`. Tests run on `node:test`, so `describe`, `it` and `mock` are imported, not globals. -Every test that needs a node uses `createVirtualDevice()` from `test/util`: the code under test -(`zclNode`) and a scripted device (`virtualDevice`) on two nodes, with an in-memory link that logs -every frame (`frames`), can drop or rewrite them (`intercept`) and waits for them (`settle`). Read -[docs/testing.md](docs/testing.md) before writing a test. The rules that matter most: - -- Never assert inside a `BoundCluster` or cluster handler: the endpoint turns the thrown assertion - into an error Default Response. Store what the handler received, assert after the `await`. -- No `done` callbacks; `await settle()` before asserting that something did not happen. -- Assert on the bytes in `frames` when the encoding is the point, not on a round trip through the - same definition. -- A known bug is a test with `{ todo: '...' }`; check it fails on the bug, not on the test. +Every test that needs a node uses `createVirtualDevice()` from `test/util`. Read +[docs/testing.md](docs/testing.md) before writing a test, above all its rules: an assertion inside a +`BoundCluster` or cluster handler becomes an error Default Response instead of a failure. diff --git a/docs/testing.md b/docs/testing.md index a2acbd8d..47d73448 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -22,7 +22,8 @@ zclNode <-- in-memory link: logs, intercepts, 82-byte limit --> virtualDevice to `zclNode`. Both sides run the library's real frame parsing, encoding, response matching and default response -rules. Only the stack below `ZCLNode.sendFrame` is replaced. +rules. Only the stack below `ZCLNode.sendFrame` is replaced. As on Homey, `sendFrame` resolves +before the other side sees the frame, so a command's response timeout runs for every command. ## Quick start @@ -56,24 +57,28 @@ it('reads the on/off state', async function () { Attribute values become a `BoundCluster` that answers reads and accepts writes. Pass your own `BoundCluster` subclass to script commands or misbehave. A cluster can also be bound later with -`virtualDevice.endpoints[n].bind()`. +`virtualDevice.endpoints[n].bind()`. For the common case of one endpoint with one cluster, +`createSingleClusterDevice(OnOffCluster, boundCluster)` is the short form. It returns: -- `zclNode`: the node under test. -- `virtualDevice`: the device node. `virtualDevice.sendFrame(endpointId, clusterId, buffer, meta)` +- `zclNode` and `virtualDevice`: the two nodes. `virtualDevice.sendFrame(endpointId, clusterId, buffer, meta)` sends any raw frame to `zclNode`; `meta` is what the stack would pass along, such as `{ groupId }` or `{ broadcast: true }`. -- `frames`: every delivered frame, in order, as `{ from, endpointId, clusterId, frame, raw, error }`. - `from` is `'zclNode'` or `'virtualDevice'`, `frame` the parsed header (`undefined` when - malformed), and `error` is set when the receiver threw. +- `frames`: every frame sent over the link, in order, as `{ from, endpointId, clusterId, frame, +raw }`. `from` is `'zclNode'` or `'virtualDevice'` and `frame` the parsed header. Frames an + interceptor dropped are not in it. - `intercept(fn)`: `fn(record)` sees each frame before delivery and returns `undefined` to pass it, `null` to drop it, or a buffer or array of buffers to deliver instead. Throwing makes the sender's `sendFrame` reject, as a stack error does. -- `settle()`: resolves once every frame in flight, and each frame sent in reply, has been handled. +- `settle()`: resolves once every frame in flight, and each frame sent while handling one, has been + handled. Work a handler or listener starts after an `await` of its own is not tracked. It rejects + when a delivery failed, including when the nodes got stuck answering each other: the link refuses + to send more than 1000 frames in total. Raise that cap in `test/util/virtualDevice.js` when a + legit test, such as a long OTA transfer, needs more. -The link also fails a send once 1000 frames have gone over it, so two nodes stuck answering each -other fail the test instead of hanging it. +`frameToClient({ cmdId, data, clusterSpecific, trxSequenceNumber, disableDefaultResponse })` builds +the server-to-client frame a device sends as a report or notification. ## Recipes @@ -119,8 +124,8 @@ intercept(({ from, frame, raw }) => ``` **Something the device starts, such as a report or a notification.** Build the frame with -`ZCLStandardHeader` (`frameControl.directionToClient = true`), send it with -`virtualDevice.sendFrame()`, then `await settle()` before asserting. See `test/attributeReport.js`. +`frameToClient()`, send it with `virtualDevice.sendFrame()`, then `await settle()` before +asserting. See `test/attributeReport.js`. **Homey serving the device.** For traffic the device starts, such as OTA requests or a remote's commands, give the device the cluster as an output cluster, bind the `BoundCluster` on `zclNode` @@ -137,7 +142,7 @@ mistake in the test, because nothing else will tell you. See `test/virtualDevice caught by the endpoint and turned into an error Default Response, so the assertion message is lost or the test passes anyway. Store what the handler received and assert after the `await`. - **No `done` callbacks.** Await the command, or `settle()`, then assert. A `done` that is never - called hangs the test instead of failing it. + called fails only after `npm test`'s 5 second timeout, and hangs a bare `node --test`. - **`await settle()` before asserting that something did not happen**, and before asserting on anything a device-initiated frame triggers. Awaiting a command only waits for its own response. - **Fake time, never wait.** Use `t.mock.timers` for response timeouts, which default to 10 seconds. @@ -149,10 +154,10 @@ mistake in the test, because nothing else will tell you. See `test/virtualDevice ## Known gaps -- The device has no typed way to send reports or server-to-client commands on its input clusters, - so those frames are built by hand. -- Interceptors run synchronously before delivery, so "deliver, then have `sendFrame` reject" or "resolve - `sendFrame` late" need a stub on top of the harness, see `test/testClusterResponseTimeout.js`. -- Dropped frames do not appear in `frames`. +- The device has no typed way to send reports or server-to-client commands on its input clusters: + `frameToClient()` builds the header, the payload bytes are written by hand. +- Interceptors run synchronously before delivery, so "deliver, then have `sendFrame` reject" or + "resolve `sendFrame` late" need a stub on top of the harness, see + `test/testClusterResponseTimeout.js`. - `test/endpointStandalone.js` builds an `Endpoint` without a node on purpose and does not use the harness. diff --git a/test/attributeReport.js b/test/attributeReport.js index fb16522f..90f2c6cd 100644 --- a/test/attributeReport.js +++ b/test/attributeReport.js @@ -3,8 +3,7 @@ const { describe, it, mock } = require('node:test'); const assert = require('node:assert'); const OnOffCluster = require('../lib/clusters/onOff'); -const { createVirtualDevice } = require('./util'); -const { ZCLStandardHeader } = require('../lib/zclFrames'); +const { createVirtualDevice, frameToClient } = require('./util'); describe('Attribute report', function () { it('should emit the transaction sequence number with the attribute value', async function () { @@ -14,13 +13,13 @@ describe('Attribute report', function () { const onOnOff = mock.fn(); zclNode.endpoints[1].clusters.onOff.on('attr.onOff', onOnOff); - const frame = new ZCLStandardHeader(); - frame.cmdId = 0x0a; // reportAttributes - frame.trxSequenceNumber = 42; - frame.frameControl.directionToClient = true; - // attributeId 0x0000 (onOff), dataType 0x10 (bool), value true - frame.data = Buffer.from([0x00, 0x00, 0x10, 0x01]); - await virtualDevice.sendFrame(1, OnOffCluster.ID, frame.toBuffer(), { + const report = frameToClient({ + cmdId: 0x0a, // reportAttributes + trxSequenceNumber: 42, + // attributeId 0x0000 (onOff), dataType 0x10 (bool), value true + data: Buffer.from([0x00, 0x00, 0x10, 0x01]), + }); + await virtualDevice.sendFrame(1, OnOffCluster.ID, report, { dstEndpoint: 1, linkQuality: 200, }); diff --git a/test/doorLock.js b/test/doorLock.js index 33db3d35..912a8ab1 100644 --- a/test/doorLock.js +++ b/test/doorLock.js @@ -4,8 +4,7 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); const DoorLockCluster = require('../lib/clusters/doorLock'); -const { createVirtualDevice } = require('./util'); -const { ZCLStandardHeader } = require('../lib/zclFrames'); +const { createVirtualDevice, frameToClient } = require('./util'); describe('Door Lock', function () { it('should receive lockDoor', async function () { @@ -138,15 +137,15 @@ describe('Door Lock', function () { data = received; }; - const frame = new ZCLStandardHeader(); - frame.cmdId = DoorLockCluster.COMMANDS.operationEventNotification.id; - frame.frameControl.directionToClient = true; - frame.frameControl.clusterSpecific = true; - frame.data = Buffer.from([ - 0x01, 0x02, 0x03, 0x00, 0x04, 0x31, 0x32, 0x33, 0x34, 0x78, 0x56, 0x34, 0x12, 0x00, - ]); + const frame = frameToClient({ + cmdId: DoorLockCluster.COMMANDS.operationEventNotification.id, + clusterSpecific: true, + data: Buffer.from([ + 0x01, 0x02, 0x03, 0x00, 0x04, 0x31, 0x32, 0x33, 0x34, 0x78, 0x56, 0x34, 0x12, 0x00, + ]), + }); - await virtualDevice.sendFrame(1, DoorLockCluster.ID, frame.toBuffer()); + await virtualDevice.sendFrame(1, DoorLockCluster.ID, frame); await settle(); assert.strictEqual(data.operationEventSource, 1); // Keypad @@ -172,16 +171,16 @@ describe('Door Lock', function () { data = received; }; - const frame = new ZCLStandardHeader(); - frame.cmdId = DoorLockCluster.COMMANDS.programmingEventNotification.id; - frame.frameControl.directionToClient = true; - frame.frameControl.clusterSpecific = true; - frame.data = Buffer.from([ - 0x02, 0x03, 0x07, 0x00, 0x04, 0x35, 0x36, 0x37, 0x38, 0x00, 0x01, 0xdd, 0xcc, 0xbb, 0xaa, - 0x00, - ]); + const frame = frameToClient({ + cmdId: DoorLockCluster.COMMANDS.programmingEventNotification.id, + clusterSpecific: true, + data: Buffer.from([ + 0x02, 0x03, 0x07, 0x00, 0x04, 0x35, 0x36, 0x37, 0x38, 0x00, 0x01, 0xdd, 0xcc, 0xbb, 0xaa, + 0x00, + ]), + }); - await virtualDevice.sendFrame(1, DoorLockCluster.ID, frame.toBuffer()); + await virtualDevice.sendFrame(1, DoorLockCluster.ID, frame); await settle(); assert.strictEqual(data.programEventSource, 2); // RF diff --git a/test/iasZone.js b/test/iasZone.js index 4b245d85..e9e2784d 100644 --- a/test/iasZone.js +++ b/test/iasZone.js @@ -4,8 +4,7 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); const IASZoneCluster = require('../lib/clusters/iasZone'); -const { createVirtualDevice } = require('./util'); -const { ZCLStandardHeader } = require('../lib/zclFrames'); +const { createVirtualDevice, frameToClient } = require('./util'); /** Has an IAS zone sensor send the cluster-specific `cmdId` with `data` to the node under test. */ async function receiveFromSensor(cmdId, data, handlerName) { @@ -18,12 +17,8 @@ async function receiveFromSensor(cmdId, data, handlerName) { received = payload; }; - const frame = new ZCLStandardHeader(); - frame.cmdId = cmdId; - frame.frameControl.directionToClient = true; - frame.frameControl.clusterSpecific = true; - frame.data = data; - await virtualDevice.sendFrame(1, IASZoneCluster.ID, frame.toBuffer()); + const frame = frameToClient({ cmdId, data, clusterSpecific: true }); + await virtualDevice.sendFrame(1, IASZoneCluster.ID, frame); await settle(); return received; diff --git a/test/testClusterResponseTimeout.js b/test/testClusterResponseTimeout.js index d941164c..a6ed0568 100644 --- a/test/testClusterResponseTimeout.js +++ b/test/testClusterResponseTimeout.js @@ -10,19 +10,11 @@ const OnOffCluster = require('../lib/clusters/onOff'); const BasicCluster = require('../lib/clusters/basic'); const AlarmsCluster = require('../lib/clusters/alarms'); const IasZoneCluster = require('../lib/clusters/iasZone'); -const { createVirtualDevice } = require('./util'); - -/** A device whose `clusterName` cluster is served by `bound`. */ -function deviceServing(clusterId, clusterName, bound) { - return createVirtualDevice({ - endpoints: [{ endpointId: 1, inputClusters: [clusterId], clusters: { [clusterName]: bound } }], - }); -} +const { createSingleClusterDevice } = require('./util'); const togglingDevice = () => - deviceServing( - OnOffCluster.ID, - 'onOff', + createSingleClusterDevice( + OnOffCluster, new (class extends BoundCluster { toggle() {} })(), @@ -58,9 +50,8 @@ describe('Cluster Response Timeout', function () { it('should clear timeout when response is received', async function (t) { const clearTimeoutSpy = t.mock.method(global, 'clearTimeout'); - const { zclNode } = deviceServing( - OnOffCluster.ID, - 'onOff', + const { zclNode } = createSingleClusterDevice( + OnOffCluster, new (class extends BoundCluster { async toggle() { // Respond after sendFrame resolved @@ -107,9 +98,8 @@ describe('Cluster Response Timeout', function () { }); it('should resolve getAlarm with a Get Alarm Response alone', async function () { - const { zclNode } = deviceServing( - AlarmsCluster.ID, - 'alarms', + const { zclNode } = createSingleClusterDevice( + AlarmsCluster, new (class extends BoundCluster { getAlarm() { return { @@ -154,9 +144,8 @@ describe('Cluster Response Timeout', function () { Cluster.addCluster(TuyaLikeCluster); t.after(() => Cluster.removeCluster(TuyaLikeCluster.ID)); - const { zclNode, virtualDevice } = deviceServing( - TuyaLikeCluster.ID, - 'tuyaLike', + const { zclNode, virtualDevice } = createSingleClusterDevice( + TuyaLikeCluster, new (class extends BoundCluster { // Reply like a Tuya MCU: its own command with the request's TSN, no Default Response async datapoint({ value }, meta, frame) { @@ -205,9 +194,8 @@ describe('Cluster Response Timeout', function () { Cluster.addCluster(EchoCluster); t.after(() => Cluster.removeCluster(EchoCluster.ID)); - const { zclNode, intercept } = deviceServing( - EchoCluster.ID, - 'echo', + const { zclNode, intercept } = createSingleClusterDevice( + EchoCluster, new (class extends BoundCluster { async ping({ value }) { return { value }; @@ -224,9 +212,8 @@ describe('Cluster Response Timeout', function () { describe('pending response matching', function () { it('should not resolve a global command on a cluster-specific frame with its response id', async function () { - const { zclNode, intercept } = deviceServing( - IasZoneCluster.ID, - 'iasZone', + const { zclNode, intercept } = createSingleClusterDevice( + IasZoneCluster, new (class extends BoundCluster { get zoneState() { return 'enrolled'; @@ -277,9 +264,8 @@ describe('Cluster Response Timeout', function () { }); it('should ignore a duplicate response without sending an error back', async function () { - const { zclNode, frames, intercept, settle } = deviceServing( - BasicCluster.ID, - 'basic', + const { zclNode, frames, intercept, settle } = createSingleClusterDevice( + BasicCluster, new (class extends BoundCluster { async readAttributesStructured() { // Attribute 0x0004, SUCCESS, string "_TZ3000" diff --git a/test/testMfgSpecificCommands.js b/test/testMfgSpecificCommands.js index 0f0f5359..0aa20f0f 100644 --- a/test/testMfgSpecificCommands.js +++ b/test/testMfgSpecificCommands.js @@ -73,17 +73,14 @@ class IkeaSceneCluster extends SceneCluster { } Cluster.addCluster(IkeaSceneCluster); -const { createVirtualDevice } = require('./util'); +const { createSingleClusterDevice } = require('./util'); /** * Sends `command` from a scenes cluster to a virtual device that answers with BoundCluster's * defaults, and returns what went on the wire. */ async function send(command, bound = new BoundCluster()) { - const { zclNode, virtualDevice, frames } = createVirtualDevice({ - endpoints: [{ endpointId: 1, inputClusters: [SceneCluster.ID] }], - }); - virtualDevice.endpoints[1].bind('scenes', bound); + const { zclNode, frames } = createSingleClusterDevice(SceneCluster, bound); const result = command(zclNode.endpoints[1].clusters.scenes); return { result, frames }; @@ -102,7 +99,7 @@ async function assertSentManufacturerId(command, manufacturerId) { /** Asserts that mixing manufacturers is rejected before anything is sent. */ async function assertRejectsMixedManufacturers(command) { const { result, frames } = await send(command); - await assert.rejects(result); + await assert.rejects(result, /multiple manufacturer ids/); assert.deepStrictEqual(frames, []); } diff --git a/test/testNode.js b/test/testNode.js index b6d2aef2..dfc8224e 100644 --- a/test/testNode.js +++ b/test/testNode.js @@ -3,7 +3,7 @@ const { describe, it, mock } = require('node:test'); const assert = require('node:assert'); -const { createVirtualDevice } = require('./util'); +const { createVirtualDevice, createSingleClusterDevice } = require('./util'); const BoundCluster = require('../lib/BoundCluster'); const Cluster = require('../lib/Cluster'); const { ZCLDataTypes } = require('../lib/zclTypes'); @@ -13,15 +13,6 @@ const OnOffCluster = require('../lib/clusters/onOff'); // Cluster-specific, client to server, TSN 1, command 0x02 (toggle) const TOGGLE = Buffer.from([0x01, 0x01, 0x02]); -/** A device with a basic cluster, served by `basic` when given. */ -function basicDevice(basic) { - return createVirtualDevice({ - endpoints: [ - { endpointId: 1, inputClusters: [BasicCluster.ID], clusters: basic ? { basic } : {} }, - ], - }); -} - /** A remote that sends onOff commands to the node under test, which serves them with `onOff`. */ function remoteSendingTo(onOff) { const virtual = createVirtualDevice({ @@ -36,7 +27,8 @@ const summary = (frames) => frames.map(({ from, frame }) => [from, frame.cmdId]) describe('Node', function () { it('should configure reporting', async function () { let received; - const { zclNode } = basicDevice( + const { zclNode } = createSingleClusterDevice( + BasicCluster, new (class extends BoundCluster { async configureReporting({ reports }) { received = reports; @@ -52,7 +44,8 @@ describe('Node', function () { }); it('should read attributes', async function () { - const { zclNode } = basicDevice( + const { zclNode } = createSingleClusterDevice( + BasicCluster, new (class extends BoundCluster { constructor() { super(); @@ -84,7 +77,8 @@ describe('Node', function () { }); it('should read attributes by numeric ID', async function () { - const { zclNode } = basicDevice( + const { zclNode } = createSingleClusterDevice( + BasicCluster, new (class extends BoundCluster { get modelId() { return 'numericTest'; @@ -98,7 +92,8 @@ describe('Node', function () { }); it('should read attributes with mixed strings and numeric IDs', async function () { - const { zclNode } = basicDevice( + const { zclNode } = createSingleClusterDevice( + BasicCluster, new (class extends BoundCluster { get modelId() { return 'mixedTest'; @@ -117,7 +112,8 @@ describe('Node', function () { }); it('should return both name and numeric key when same attribute requested both ways', async function () { - const { zclNode } = basicDevice( + const { zclNode } = createSingleClusterDevice( + BasicCluster, new (class extends BoundCluster { get modelId() { return 'both'; @@ -131,7 +127,7 @@ describe('Node', function () { }); it('should read an unregistered attribute by numeric ID', async function () { - const { zclNode } = basicDevice(new BoundCluster()); + const { zclNode } = createSingleClusterDevice(BasicCluster, new BoundCluster()); // 0x1234 is not registered in the basic cluster, expect no error and empty/missing result const res = await zclNode.endpoints[1].clusters.basic.readAttributes([0x1234]); @@ -139,7 +135,7 @@ describe('Node', function () { }); it('should throw for invalid numeric attribute IDs', async function () { - const { zclNode, frames } = basicDevice(new BoundCluster()); + const { zclNode, frames } = createSingleClusterDevice(BasicCluster, new BoundCluster()); const { basic } = zclNode.endpoints[1].clusters; for (const id of [-1, 0x10000, 1.5, NaN]) { @@ -149,7 +145,8 @@ describe('Node', function () { }); it('should read attributes without a default response when disableDefaultResponse is true', async function () { - const { zclNode, frames, settle } = basicDevice( + const { zclNode, frames, settle } = createSingleClusterDevice( + BasicCluster, new (class extends BoundCluster { get modelId() { return 'test'; @@ -233,7 +230,8 @@ describe('Node', function () { it('should write attributes', async function () { let written; - const { zclNode } = basicDevice( + const { zclNode } = createSingleClusterDevice( + BasicCluster, new (class extends BoundCluster { get modelId() { return 'test'; @@ -251,7 +249,8 @@ describe('Node', function () { }); it('should discover attributes', async function () { - const { zclNode } = basicDevice( + const { zclNode } = createSingleClusterDevice( + BasicCluster, new (class extends BoundCluster { get modelId() { return 'test'; @@ -270,7 +269,8 @@ describe('Node', function () { }); it('should discover received commands', async function () { - const { zclNode } = basicDevice( + const { zclNode } = createSingleClusterDevice( + BasicCluster, new (class extends BoundCluster { async factoryReset() {} })(), diff --git a/test/testZCLError.js b/test/testZCLError.js index 5924935c..2d6836bd 100644 --- a/test/testZCLError.js +++ b/test/testZCLError.js @@ -6,25 +6,16 @@ const BoundCluster = require('../lib/BoundCluster'); const OnOffCluster = require('../lib/clusters/onOff'); const LevelControlCluster = require('../lib/clusters/levelControl'); const IasZoneCluster = require('../lib/clusters/iasZone'); -const { ZCLStandardHeader } = require('../lib/zclFrames'); const { ZCLError } = require('../lib/util'); -const { createVirtualDevice } = require('./util'); +const { createVirtualDevice, createSingleClusterDevice, frameToClient } = require('./util'); const SET_ON_CMD_ID = OnOffCluster.COMMANDS.setOn.id; const DEFAULT_RESPONSE_CMD_ID = 0x0b; -/** A device whose onOff cluster is served by `onOff`, when given. */ -function onOffDevice(onOff) { - return createVirtualDevice({ - endpoints: [ - { endpointId: 1, inputClusters: [OnOffCluster.ID], clusters: onOff ? { onOff } : {} }, - ], - }); -} - /** A device whose setOn handler throws `error`. */ function onOffDeviceThrowing(error) { - return onOffDevice( + return createSingleClusterDevice( + OnOffCluster, new (class extends BoundCluster { async setOn() { throw error; @@ -46,14 +37,8 @@ async function nodeAnswerTo(clusterId, cmdId, data, inputClusters = [OnOffCluste const { virtualDevice, frames, settle } = createVirtualDevice({ endpoints: [{ endpointId: 1, inputClusters }], }); - const header = new ZCLStandardHeader(); - header.frameControl.clusterSpecific = true; - header.frameControl.directionToClient = true; - header.trxSequenceNumber = 1; - header.cmdId = cmdId; - header.data = data; - - await virtualDevice.sendFrame(1, clusterId, header.toBuffer()); + const frame = frameToClient({ cmdId, data, clusterSpecific: true, trxSequenceNumber: 1 }); + await virtualDevice.sendFrame(1, clusterId, frame); await settle(); const answers = frames.filter(({ from }) => from === 'zclNode'); @@ -90,7 +75,7 @@ describe('ZCLError handling', function () { // ZCL R8 2.5.12.2 it('should send UNSUP_COMMAND (0x81) for a command the BoundCluster does not implement', async function () { - const { zclNode, frames } = onOffDevice(new BoundCluster()); + const { zclNode, frames } = createSingleClusterDevice(OnOffCluster, new BoundCluster()); await assert.rejects(zclNode.endpoints[1].clusters.onOff.setOn(), { message: 'UNSUP_CLUSTER_COMMAND', @@ -122,7 +107,7 @@ describe('ZCLError handling', function () { }); it('should send UNSUPPORTED_CLUSTER (0xc3) when the endpoint has no binding for the cluster', async function () { - const { zclNode, frames } = onOffDevice(); + const { zclNode, frames } = createSingleClusterDevice(OnOffCluster); await assert.rejects(zclNode.endpoints[1].clusters.onOff.setOn(), { message: 'UNSUPPORTED_CLUSTER', diff --git a/test/util/virtualDevice.js b/test/util/virtualDevice.js index 32ec4c91..5908b1f5 100644 --- a/test/util/virtualDevice.js +++ b/test/util/virtualDevice.js @@ -1,29 +1,25 @@ 'use strict'; +// Taken before a test can mock timers: delivery must not stop when a test fakes time. +const { setImmediate } = require('node:timers'); + const Node = require('../../lib/Node'); const Endpoint = require('../../lib/Endpoint'); const BoundCluster = require('../../lib/BoundCluster'); +const { ZCLStandardHeader } = require('../../lib/zclFrames'); // Homey's Zigbee stack rejects a longer ZCL frame (MAX_ZCL_BYTES in @athombv/z3gateway). const HOMEY_MAX_ZCL_BYTES = 82; +// ponytail: counts every frame of a device, raise it when a legit test (e.g. an OTA transfer) +// sends more const MAX_FRAMES = 1000; -// A malformed frame is still delivered as it is, it just has no parsed form in the log. -function parse(raw) { - try { - return Endpoint.parseFrame(raw); - } catch { - return undefined; - } -} - /** A BoundCluster serving `attributes`: reads return them, writes replace them. */ function boundClusterWithAttributes(attributes) { const values = { ...attributes }; const bound = new BoundCluster(); - // Accessors, not plain fields: BoundCluster.writeAttributes treats a field without a setter as - // read-only. + // Accessors: writeAttributes treats a field without a setter as read-only. for (const name of Object.keys(values)) { Object.defineProperty(bound, name, { get: () => values[name], @@ -37,45 +33,15 @@ function boundClusterWithAttributes(attributes) { } /** - * Creates a virtual device and the ZCLNode a driver would get for it, connected by an in-memory - * link that records every frame and lets a test drop, rewrite or inject frames. - * - * @example - * const { zclNode, virtualDevice, frames, intercept, settle } = createVirtualDevice({ - * endpoints: [{ - * endpointId: 1, - * inputClusters: [OnOffCluster.ID], - * clusters: { onOff: { onOff: true } }, - * }], - * }); - * - * // The device never answers - * intercept((frame) => (frame.from === 'virtualDevice' ? null : undefined)); - * - * @param {Object} config - * @param {Array} config.endpoints - As the device describes them: `endpointId`, `inputClusters`, - * `outputClusters`, and `clusters` keyed by cluster name, each either attribute values or a - * `BoundCluster` instance that scripts the device's behavior. - * @param {number} [config.maxFrameSize] - Longest frame the zclNode may send, as the Homey stack. - * @returns {{ - * zclNode: Node, - * device: Node, - * frames: Array<{from: string, endpointId: number, clusterId: number, frame: Object, raw: Buffer, error?: Error}>, - * intercept: function(function(Object): (undefined|null|Buffer|Buffer[])): void, - * settle: function(): Promise, - * }} - * `zclNode` is the node under test, as a driver gets it in `onNodeInit`. `virtualDevice` sends - * commands through `virtualDevice.endpoints[n].clusters` and any other frame, such as an attribute - * report, with `virtualDevice.sendFrame(endpointId, clusterId, buffer, meta)`, where `meta` is the - * frame meta the stack would pass along. An interceptor sees each frame before delivery and returns `undefined` to pass it, `null` to drop it, or one or more buffers to - * deliver instead; throwing fails the sender's `sendFrame` like a stack error. `settle` resolves - * once every frame in flight, and the frames sent in reply, has been handled. + * Creates the node under test (`zclNode`) and a scripted device (`virtualDevice`), linked in + * memory. See docs/testing.md for the options, what it returns and how to use it. */ function createVirtualDevice({ endpoints, maxFrameSize = HOMEY_MAX_ZCL_BYTES }) { const frames = []; const interceptors = []; const inFlight = new Set(); const nodes = {}; + let failure; const link = (from, to) => ({ endpointDescriptors: endpoints.map((ep) => ({ @@ -84,9 +50,10 @@ function createVirtualDevice({ endpoints, maxFrameSize = HOMEY_MAX_ZCL_BYTES }) outputClusters: ep.outputClusters || [], })), async sendFrame(endpointId, clusterId, data, meta) { - // Breaks an endless request/response loop between the nodes, so the test fails, not hangs. if (frames.length >= MAX_FRAMES) { - throw new Error(`More than ${MAX_FRAMES} frames sent, are the nodes stuck in a loop?`); + // The node sending this may swallow the error, so settle() rethrows it. + failure = failure || new Error(`More than ${MAX_FRAMES} frames, are the nodes in a loop?`); + throw failure; } if (from === 'zclNode' && data.length > maxFrameSize) { throw new Error(`ZCL frame too long, got ${data.length} bytes, max: ${maxFrameSize}.`); @@ -95,20 +62,28 @@ function createVirtualDevice({ endpoints, maxFrameSize = HOMEY_MAX_ZCL_BYTES }) let raws = [data]; for (const interceptor of interceptors) { raws = raws.flatMap((raw) => { - const result = interceptor({ from, endpointId, clusterId, frame: parse(raw), raw }); + const result = interceptor({ + from, + endpointId, + clusterId, + frame: Endpoint.parseFrame(raw), + raw, + }); if (result === undefined) return [raw]; if (result === null) return []; return result; }); } + if (!raws.every(Buffer.isBuffer)) throw new TypeError('An interceptor returned a non-Buffer'); for (const raw of raws) { - const record = { from, endpointId, clusterId, frame: parse(raw), raw }; - frames.push(record); - const delivery = nodes[to] - .handleFrame(endpointId, clusterId, raw, meta) + frames.push({ from, endpointId, clusterId, frame: Endpoint.parseFrame(raw), raw }); + // Delivered after sendFrame resolves, as on Homey where sendFrame resolves on the APS ACK + // and the response comes later, so a command's response timeout really runs. + const delivery = new Promise((resolve) => setImmediate(resolve)) + .then(() => nodes[to].handleFrame(endpointId, clusterId, raw, meta)) .catch((err) => { - record.error = err; + failure = failure || err; }) .finally(() => inFlight.delete(delivery)); inFlight.add(delivery); @@ -142,8 +117,40 @@ function createVirtualDevice({ endpoints, maxFrameSize = HOMEY_MAX_ZCL_BYTES }) intercept: (interceptor) => interceptors.push(interceptor), async settle() { while (inFlight.size) await Promise.all(inFlight); + if (failure) throw failure; }, }; } -module.exports = { createVirtualDevice, HOMEY_MAX_ZCL_BYTES }; +/** A device with endpoint 1 serving `ClusterClass`, by `bound` when given. */ +function createSingleClusterDevice(ClusterClass, bound) { + return createVirtualDevice({ + endpoints: [ + { + endpointId: 1, + inputClusters: [ClusterClass.ID], + clusters: bound ? { [ClusterClass.NAME]: bound } : {}, + }, + ], + }); +} + +/** A server-to-client frame, as a device sends a report or a notification. */ +function frameToClient({ + cmdId, + data = Buffer.alloc(0), + clusterSpecific = false, + trxSequenceNumber = 0, + disableDefaultResponse = false, +}) { + const header = new ZCLStandardHeader(); + header.frameControl.directionToClient = true; + header.frameControl.clusterSpecific = clusterSpecific; + header.frameControl.disableDefaultResponse = disableDefaultResponse; + header.trxSequenceNumber = trxSequenceNumber; + header.cmdId = cmdId; + header.data = data; + return header.toBuffer(); +} + +module.exports = { createVirtualDevice, createSingleClusterDevice, frameToClient }; diff --git a/test/virtualDevice.js b/test/virtualDevice.js index 85b81ae4..8efefbc4 100644 --- a/test/virtualDevice.js +++ b/test/virtualDevice.js @@ -6,37 +6,11 @@ const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); const OnOffCluster = require('../lib/clusters/onOff'); const MeteringCluster = require('../lib/clusters/metering'); -const { ZCLStandardHeader } = require('../lib/zclFrames'); -const { createVirtualDevice } = require('./util'); +const { createVirtualDevice, createSingleClusterDevice, frameToClient } = require('./util'); const ON_OFF = OnOffCluster.ATTRIBUTES.onOff.id; const ON_TIME = OnOffCluster.ATTRIBUTES.onTime.id; -/** A plug whose onOff cluster answers reads with what `answer(requestedIds)` returns. */ -function plugAnswering(answer) { - return createVirtualDevice({ - endpoints: [ - { - endpointId: 1, - inputClusters: [OnOffCluster.ID], - clusters: { - onOff: new (class extends BoundCluster { - async readAttributes({ attributes }) { - const buf = Buffer.alloc(255); - const len = OnOffCluster.attributeArrayStatusDataType.toBuffer( - buf, - answer(attributes), - 0, - ); - return { attributes: buf.slice(0, len) }; - } - })(), - }, - }, - ], - }); -} - describe('Virtual device', function () { it('answers a read and records both frames', async function () { const { zclNode, frames } = createVirtualDevice({ @@ -93,6 +67,29 @@ describe('Virtual device', function () { await assert.rejects(zclNode.endpoints[1].clusters.onOff.toggle(), /ZCL frame too long/); }); + + it('fails settle when the nodes keep answering each other', async function () { + const { zclNode, virtualDevice, settle } = createVirtualDevice({ + endpoints: [ + { endpointId: 1, inputClusters: [OnOffCluster.ID], outputClusters: [OnOffCluster.ID] }, + ], + }); + // Each side answers a toggle with a toggle of its own. Not waiting for responses keeps no + // response timers alive after the test. + const NO_WAIT = { waitForResponse: false }; + const toggleBack = (node) => + new (class extends BoundCluster { + toggle() { + node.endpoints[1].clusters.onOff.toggle(undefined, NO_WAIT).catch(() => {}); + } + })(); + virtualDevice.endpoints[1].bind('onOff', toggleBack(virtualDevice)); + zclNode.endpoints[1].bind('onOff', toggleBack(zclNode)); + + zclNode.endpoints[1].clusters.onOff.toggle(undefined, NO_WAIT).catch(() => {}); + + await assert.rejects(settle(), /are the nodes in a loop/); + }); }); describe('Virtual device: known bugs on master', function () { @@ -100,9 +97,18 @@ describe('Virtual device: known bugs on master', function () { 'readAttributes stops when the device answers with unrequested ids', { todo: 'fixed by #258' }, async function () { - const { zclNode, intercept } = plugAnswering(() => [ - { id: ON_TIME, status: 'SUCCESS', value: 5 }, - ]); + // Whatever is asked, the plug answers with onTime only + const { zclNode, intercept } = createSingleClusterDevice( + OnOffCluster, + new (class extends BoundCluster { + async readAttributes() { + const buf = Buffer.alloc(255); + const records = [{ id: ON_TIME, status: 'SUCCESS', value: 5 }]; + const len = OnOffCluster.attributeArrayStatusDataType.toBuffer(buf, records, 0); + return { attributes: buf.subarray(0, len) }; + } + })(), + ); let reads = 0; intercept((frame) => { // Guard: on master the read loops forever. @@ -173,13 +179,13 @@ describe('Virtual device: known bugs on master', function () { onOff.on('attr.undefined', onUndefined); onOff.on('attr.onOff', onOnOff); - const report = new ZCLStandardHeader(); - report.cmdId = 0x0a; // reportAttributes - report.frameControl.directionToClient = true; - report.frameControl.disableDefaultResponse = true; - // 0x4242 uint8 (0x20) 1, then onOff bool (0x10) true - report.data = Buffer.from([0x42, 0x42, 0x20, 0x01, ON_OFF, 0x00, 0x10, 0x01]); - await virtualDevice.sendFrame(1, OnOffCluster.ID, report.toBuffer()); + const report = frameToClient({ + cmdId: 0x0a, // reportAttributes + disableDefaultResponse: true, + // 0x4242 uint8 (0x20) 1, then onOff bool (0x10) true + data: Buffer.from([0x42, 0x42, 0x20, 0x01, ON_OFF, 0x00, 0x10, 0x01]), + }); + await virtualDevice.sendFrame(1, OnOffCluster.ID, report); await settle(); assert.deepStrictEqual( diff --git a/test/xiaomiLifeline.js b/test/xiaomiLifeline.js index 3f61f33d..0e3aae95 100644 --- a/test/xiaomiLifeline.js +++ b/test/xiaomiLifeline.js @@ -1,9 +1,7 @@ 'use strict'; -// Xiaomi and Aqara devices report their state as Basic attribute 0xff01, typed on the wire as a -// character string (0x42) but holding a list of id/type/value records. A cluster subclass can -// declare its own data type for that attribute, and a report must then be decoded with the -// declared type rather than the one on the wire. +// Xiaomi devices report state in Basic attribute 0xff01: a string (0x42) on the wire holding +// id/type/value records. A cluster that declares its own type for it must get that type used. const { describe, it, mock } = require('node:test'); const assert = require('node:assert'); From b5658859b8588b6337b7a38c40b341a6eae877fb Mon Sep 17 00:00:00 2001 From: Robin Bolscher Date: Tue, 27 Oct 2026 13:27:30 +0100 Subject: [PATCH 6/8] test: Import the harness directly instead of through test/util/index.js The index only re-exported test/util/virtualDevice.js. docs/adding-clusters.md still pointed at the removed mock node helpers there; it now links the testing guide. --- AGENTS.md | 6 +++--- docs/adding-clusters.md | 2 +- docs/testing.md | 12 ++++++------ test/attributeReport.js | 2 +- test/colorControl.js | 2 +- test/defaultResponse.js | 2 +- test/doorLock.js | 2 +- test/iasZone.js | 2 +- test/levelControl.js | 2 +- test/onOff.js | 2 +- test/ota.js | 2 +- test/testClusterResponseTimeout.js | 2 +- test/testMfgSpecificCommands.js | 2 +- test/testNode.js | 2 +- test/testTrxSequenceNumber.js | 2 +- test/testZCLError.js | 6 +++++- test/util/index.js | 3 --- test/virtualDevice.js | 6 +++++- test/xiaomiLifeline.js | 2 +- 19 files changed, 33 insertions(+), 28 deletions(-) delete mode 100644 test/util/index.js diff --git a/AGENTS.md b/AGENTS.md index a9612f77..5fee00da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,6 @@ Refer to a cluster id as `OnOffCluster.ID`, never as `6` or `0x0006`. Tests run on `node:test`, so `describe`, `it` and `mock` are imported, not globals. -Every test that needs a node uses `createVirtualDevice()` from `test/util`. Read -[docs/testing.md](docs/testing.md) before writing a test, above all its rules: an assertion inside a -`BoundCluster` or cluster handler becomes an error Default Response instead of a failure. +Every test that needs a node uses `createVirtualDevice()` from `test/util/virtualDevice.js`. +Read [docs/testing.md](docs/testing.md) before writing a test, above all its rules: an assertion +inside a `BoundCluster` or cluster handler becomes an error Default Response instead of a failure. diff --git a/docs/adding-clusters.md b/docs/adding-clusters.md index 1813847e..d3d6d031 100644 --- a/docs/adding-clusters.md +++ b/docs/adding-clusters.md @@ -127,7 +127,7 @@ lists. It sits at module level because `imageBlockRequest`, `imagePageRequest` a A definition is worth a test when it encodes or decodes something a plain attribute list does not: command arguments, a variant payload, a notification. `test/onOff.js` is the smallest example, -`test/ota.js` covers variant fields. The mock node helpers live in `test/util/index.js`. +`test/ota.js` covers variant fields. [Writing tests](testing.md) explains the test harness. ## Before opening a pull request diff --git a/docs/testing.md b/docs/testing.md index 47d73448..8442f5f7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,10 +1,10 @@ # Writing tests -Every test that needs a node uses one harness, `createVirtualDevice()` from `test/util`. It puts the -code under test and a scripted Zigbee device on two separate nodes, connected by an in-memory link -that records every frame. No Homey and no radio are involved, so the whole suite runs in well under -a second. See [Contributing](../README.md#contributing) for the checks every pull request has to -pass. +Every test that needs a node uses one harness, `createVirtualDevice()` from +`test/util/virtualDevice.js`. It puts the code under test and a scripted Zigbee device on two +separate nodes, connected by an in-memory link that records every frame. No Homey and no radio are +involved, so the whole suite runs in well under a second. See +[Contributing](../README.md#contributing) for the checks every pull request has to pass. ## The two sides @@ -28,7 +28,7 @@ before the other side sees the frame, so a command's response timeout runs for e ## Quick start ```js -const { createVirtualDevice } = require('./util'); +const { createVirtualDevice } = require('./util/virtualDevice'); it('reads the on/off state', async function () { const { zclNode } = createVirtualDevice({ diff --git a/test/attributeReport.js b/test/attributeReport.js index 90f2c6cd..455b9bf5 100644 --- a/test/attributeReport.js +++ b/test/attributeReport.js @@ -3,7 +3,7 @@ const { describe, it, mock } = require('node:test'); const assert = require('node:assert'); const OnOffCluster = require('../lib/clusters/onOff'); -const { createVirtualDevice, frameToClient } = require('./util'); +const { createVirtualDevice, frameToClient } = require('./util/virtualDevice'); describe('Attribute report', function () { it('should emit the transaction sequence number with the attribute value', async function () { diff --git a/test/colorControl.js b/test/colorControl.js index f20b39c6..f987278b 100644 --- a/test/colorControl.js +++ b/test/colorControl.js @@ -5,7 +5,7 @@ const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); const ColorControlCluster = require('../lib/clusters/colorControl'); -const { createVirtualDevice } = require('./util'); +const { createVirtualDevice } = require('./util/virtualDevice'); // Sends colorLoopSet on a fresh node and returns the parsed outgoing frame, so tests can assert // the payload bytes against the ZCL spec instead of round-tripping through our own definition diff --git a/test/defaultResponse.js b/test/defaultResponse.js index 3be94cdb..3dd13d54 100644 --- a/test/defaultResponse.js +++ b/test/defaultResponse.js @@ -5,7 +5,7 @@ const assert = require('node:assert'); const { BoundCluster } = require('..'); const DoorLockCluster = require('../lib/clusters/doorLock'); -const { createVirtualDevice } = require('./util'); +const { createVirtualDevice } = require('./util/virtualDevice'); // Door Lock setWeekDaySchedule is cluster-specific command 0x0B, same id as global Default Response const SET_WEEK_DAY_SCHEDULE_REQUEST = Buffer.from([ diff --git a/test/doorLock.js b/test/doorLock.js index 912a8ab1..00acc8b0 100644 --- a/test/doorLock.js +++ b/test/doorLock.js @@ -4,7 +4,7 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); const DoorLockCluster = require('../lib/clusters/doorLock'); -const { createVirtualDevice, frameToClient } = require('./util'); +const { createVirtualDevice, frameToClient } = require('./util/virtualDevice'); describe('Door Lock', function () { it('should receive lockDoor', async function () { diff --git a/test/iasZone.js b/test/iasZone.js index e9e2784d..cda200ef 100644 --- a/test/iasZone.js +++ b/test/iasZone.js @@ -4,7 +4,7 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); const IASZoneCluster = require('../lib/clusters/iasZone'); -const { createVirtualDevice, frameToClient } = require('./util'); +const { createVirtualDevice, frameToClient } = require('./util/virtualDevice'); /** Has an IAS zone sensor send the cluster-specific `cmdId` with `data` to the node under test. */ async function receiveFromSensor(cmdId, data, handlerName) { diff --git a/test/levelControl.js b/test/levelControl.js index cc1a29cd..fd44e632 100644 --- a/test/levelControl.js +++ b/test/levelControl.js @@ -4,7 +4,7 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); const LevelControlCluster = require('../lib/clusters/levelControl'); -const { createVirtualDevice } = require('./util'); +const { createVirtualDevice } = require('./util/virtualDevice'); describe('Level Control', function () { it('should receive moveToLevel', async function () { diff --git a/test/onOff.js b/test/onOff.js index a2284b18..043f408a 100644 --- a/test/onOff.js +++ b/test/onOff.js @@ -4,7 +4,7 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); const OnOffCluster = require('../lib/clusters/onOff'); -const { createVirtualDevice } = require('./util'); +const { createVirtualDevice } = require('./util/virtualDevice'); describe('On/Off', function () { it('should receive setOn', async function () { diff --git a/test/ota.js b/test/ota.js index 61291db2..78661711 100644 --- a/test/ota.js +++ b/test/ota.js @@ -4,7 +4,7 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); const OTACluster = require('../lib/clusters/ota'); -const { createVirtualDevice } = require('./util'); +const { createVirtualDevice } = require('./util/virtualDevice'); // ZCL Standard Header: frameControl(1), trxSequenceNumber(1), cmdId(1), data(rest) const ZCL_HEADER_SIZE = 3; diff --git a/test/testClusterResponseTimeout.js b/test/testClusterResponseTimeout.js index a6ed0568..88bf5c63 100644 --- a/test/testClusterResponseTimeout.js +++ b/test/testClusterResponseTimeout.js @@ -10,7 +10,7 @@ const OnOffCluster = require('../lib/clusters/onOff'); const BasicCluster = require('../lib/clusters/basic'); const AlarmsCluster = require('../lib/clusters/alarms'); const IasZoneCluster = require('../lib/clusters/iasZone'); -const { createSingleClusterDevice } = require('./util'); +const { createSingleClusterDevice } = require('./util/virtualDevice'); const togglingDevice = () => createSingleClusterDevice( diff --git a/test/testMfgSpecificCommands.js b/test/testMfgSpecificCommands.js index 0aa20f0f..fc24ba55 100644 --- a/test/testMfgSpecificCommands.js +++ b/test/testMfgSpecificCommands.js @@ -73,7 +73,7 @@ class IkeaSceneCluster extends SceneCluster { } Cluster.addCluster(IkeaSceneCluster); -const { createSingleClusterDevice } = require('./util'); +const { createSingleClusterDevice } = require('./util/virtualDevice'); /** * Sends `command` from a scenes cluster to a virtual device that answers with BoundCluster's diff --git a/test/testNode.js b/test/testNode.js index dfc8224e..e91bc6b1 100644 --- a/test/testNode.js +++ b/test/testNode.js @@ -3,7 +3,7 @@ const { describe, it, mock } = require('node:test'); const assert = require('node:assert'); -const { createVirtualDevice, createSingleClusterDevice } = require('./util'); +const { createVirtualDevice, createSingleClusterDevice } = require('./util/virtualDevice'); const BoundCluster = require('../lib/BoundCluster'); const Cluster = require('../lib/Cluster'); const { ZCLDataTypes } = require('../lib/zclTypes'); diff --git a/test/testTrxSequenceNumber.js b/test/testTrxSequenceNumber.js index dfececc3..226718ba 100644 --- a/test/testTrxSequenceNumber.js +++ b/test/testTrxSequenceNumber.js @@ -6,7 +6,7 @@ const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); const BasicCluster = require('../lib/clusters/basic'); const OnOffCluster = require('../lib/clusters/onOff'); -const { createVirtualDevice } = require('./util'); +const { createVirtualDevice } = require('./util/virtualDevice'); describe('Transaction sequence number', function () { it('should share one counter across endpoints and clusters', async function () { diff --git a/test/testZCLError.js b/test/testZCLError.js index 2d6836bd..7a84b757 100644 --- a/test/testZCLError.js +++ b/test/testZCLError.js @@ -7,7 +7,11 @@ const OnOffCluster = require('../lib/clusters/onOff'); const LevelControlCluster = require('../lib/clusters/levelControl'); const IasZoneCluster = require('../lib/clusters/iasZone'); const { ZCLError } = require('../lib/util'); -const { createVirtualDevice, createSingleClusterDevice, frameToClient } = require('./util'); +const { + createVirtualDevice, + createSingleClusterDevice, + frameToClient, +} = require('./util/virtualDevice'); const SET_ON_CMD_ID = OnOffCluster.COMMANDS.setOn.id; const DEFAULT_RESPONSE_CMD_ID = 0x0b; diff --git a/test/util/index.js b/test/util/index.js deleted file mode 100644 index 63ce7ed0..00000000 --- a/test/util/index.js +++ /dev/null @@ -1,3 +0,0 @@ -'use strict'; - -module.exports = require('./virtualDevice'); diff --git a/test/virtualDevice.js b/test/virtualDevice.js index 8efefbc4..e9f19f8e 100644 --- a/test/virtualDevice.js +++ b/test/virtualDevice.js @@ -6,7 +6,11 @@ const assert = require('node:assert'); const BoundCluster = require('../lib/BoundCluster'); const OnOffCluster = require('../lib/clusters/onOff'); const MeteringCluster = require('../lib/clusters/metering'); -const { createVirtualDevice, createSingleClusterDevice, frameToClient } = require('./util'); +const { + createVirtualDevice, + createSingleClusterDevice, + frameToClient, +} = require('./util/virtualDevice'); const ON_OFF = OnOffCluster.ATTRIBUTES.onOff.id; const ON_TIME = OnOffCluster.ATTRIBUTES.onTime.id; diff --git a/test/xiaomiLifeline.js b/test/xiaomiLifeline.js index 0e3aae95..906edf70 100644 --- a/test/xiaomiLifeline.js +++ b/test/xiaomiLifeline.js @@ -9,7 +9,7 @@ const assert = require('node:assert'); const { ZCLDataTypes, ZCLDataType } = require('../lib/zclTypes'); const Cluster = require('../lib/Cluster'); const BasicCluster = require('../lib/clusters/basic'); -const { createVirtualDevice } = require('./util'); +const { createVirtualDevice } = require('./util/virtualDevice'); const LIFELINE_RECORDS = { 1: 'batteryVoltage', From 21c30b25aaa168d327bf3b70d039a40e39cb85e6 Mon Sep 17 00:00:00 2001 From: Robin Bolscher Date: Tue, 27 Oct 2026 13:30:08 +0100 Subject: [PATCH 7/8] docs: Cut the testing guide to what the code does not already say The diagram repeated the bullets under it, frameToClient was explained three times, and the misbehaving-device snippet was plain BoundCluster use. --- docs/testing.md | 151 ++++++++++++++---------------------------------- 1 file changed, 42 insertions(+), 109 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 8442f5f7..1c503e9c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,31 +1,15 @@ # Writing tests -Every test that needs a node uses one harness, `createVirtualDevice()` from -`test/util/virtualDevice.js`. It puts the code under test and a scripted Zigbee device on two -separate nodes, connected by an in-memory link that records every frame. No Homey and no radio are -involved, so the whole suite runs in well under a second. See -[Contributing](../README.md#contributing) for the checks every pull request has to pass. +Every test that needs a node uses `createVirtualDevice()` from `test/util/virtualDevice.js`. It puts +the code under test and a scripted device on two nodes, linked in memory. No Homey, no radio. -## The two sides +- `zclNode` is the node under test, a `ZCLNode` as a driver gets it. Its `endpoints[n].clusters` + talk to the device; `endpoints[n].bind()` serves what the device sends, such as OTA requests. +- `virtualDevice` is a second `ZCLNode` playing the device: `BoundCluster`s for its input clusters, + `clusters` for its output clusters. -``` -zclNode <-- in-memory link: logs, intercepts, 82-byte limit --> virtualDevice -(the code under test, (the device it talks to, - as a driver gets it in onNodeInit) scripted by the test) -``` - -- `zclNode` is a `ZCLNode`, exactly what a Homey driver gets. Its `endpoints[n].clusters` are the - client side that reads attributes and sends commands to the device; its `endpoints[n].bind()` - serves what the device sends to Homey, such as a remote's commands or OTA requests. -- `virtualDevice` is a second `ZCLNode` that plays the device. Its endpoints serve the device's - input clusters with `BoundCluster`s and, for its output clusters, have `clusters` to send commands - to `zclNode`. - -Both sides run the library's real frame parsing, encoding, response matching and default response -rules. Only the stack below `ZCLNode.sendFrame` is replaced. As on Homey, `sendFrame` resolves -before the other side sees the frame, so a command's response timeout runs for every command. - -## Quick start +Both run the library's real encoding, parsing and response matching. As on Homey, `sendFrame` +resolves before the other side sees the frame, so response timeouts run. ```js const { createVirtualDevice } = require('./util/virtualDevice'); @@ -45,65 +29,29 @@ it('reads the on/off state', async function () { ## Reference -`createVirtualDevice({ endpoints, maxFrameSize })` describes the device as its descriptors would: - -| Field | Meaning | -| ---------------------------- | -------------------------------------------------------------------------- | -| `endpoints[].endpointId` | Endpoint id, used on both sides. | -| `endpoints[].inputClusters` | Server clusters of the device. `zclNode` gets a client `Cluster` for each. | -| `endpoints[].outputClusters` | Client clusters of the device. `virtualDevice` gets a `Cluster` for each. | -| `endpoints[].clusters` | Per cluster name, attribute values or a `BoundCluster` instance. | -| `maxFrameSize` | Longest frame `zclNode` may send, default 82 bytes as Homey's stack. | +`createVirtualDevice({ endpoints, maxFrameSize })`: -Attribute values become a `BoundCluster` that answers reads and accepts writes. Pass your own -`BoundCluster` subclass to script commands or misbehave. A cluster can also be bound later with -`virtualDevice.endpoints[n].bind()`. For the common case of one endpoint with one cluster, -`createSingleClusterDevice(OnOffCluster, boundCluster)` is the short form. +- `endpoints[]`: `endpointId`, `inputClusters`, `outputClusters` as the device's descriptors, and + `clusters` keyed by name: attribute values (readable and writable) or your own `BoundCluster`. +- `maxFrameSize`: longest frame `zclNode` may send, default 82 bytes as Homey's stack. It returns: -- `zclNode` and `virtualDevice`: the two nodes. `virtualDevice.sendFrame(endpointId, clusterId, buffer, meta)` - sends any raw frame to `zclNode`; `meta` is what the stack would pass along, such as - `{ groupId }` or `{ broadcast: true }`. -- `frames`: every frame sent over the link, in order, as `{ from, endpointId, clusterId, frame, -raw }`. `from` is `'zclNode'` or `'virtualDevice'` and `frame` the parsed header. Frames an - interceptor dropped are not in it. -- `intercept(fn)`: `fn(record)` sees each frame before delivery and returns `undefined` to pass it, - `null` to drop it, or a buffer or array of buffers to deliver instead. Throwing makes the sender's - `sendFrame` reject, as a stack error does. -- `settle()`: resolves once every frame in flight, and each frame sent while handling one, has been - handled. Work a handler or listener starts after an `await` of its own is not tracked. It rejects - when a delivery failed, including when the nodes got stuck answering each other: the link refuses - to send more than 1000 frames in total. Raise that cap in `test/util/virtualDevice.js` when a - legit test, such as a long OTA transfer, needs more. - -`frameToClient({ cmdId, data, clusterSpecific, trxSequenceNumber, disableDefaultResponse })` builds -the server-to-client frame a device sends as a report or notification. - -## Recipes - -**Assert on the wire, not on a round trip.** Encoding and decoding with the same definition passes -even when that definition is wrong. When the bytes matter, compare `frames[i].raw` or -`frames[i].frame` against the specification, as `test/colorControl.js` does. +- `zclNode`, `virtualDevice`. `virtualDevice.sendFrame(endpointId, clusterId, buffer, meta)` sends a + raw frame; `meta` is what the stack passes, such as `{ groupId }` or `{ broadcast: true }`. +- `frames`: every frame sent, as `{ from, endpointId, clusterId, frame, raw }`, where `from` is + `'zclNode'` or `'virtualDevice'` and `frame` the parsed header. +- `intercept(fn)`: `fn(record)` returns `undefined` to pass a frame, `null` to drop it, or buffers to + deliver instead. Throwing makes the sender's `sendFrame` reject. +- `settle()`: waits until no frame is in flight. Rejects when a delivery failed, or after 1000 + frames, which means the nodes are answering each other in a loop. -**A device that misbehaves.** Override the handler on a `BoundCluster`, for example a read response -with ids that were not asked for: +Also exported: `createSingleClusterDevice(OnOffCluster, boundCluster)` for one endpoint with one +cluster, and `frameToClient({ cmdId, data, clusterSpecific, ... })` for a report or notification. -```js -const onOff = new (class extends BoundCluster { - async readAttributes() { - const buf = Buffer.alloc(255); - const records = [{ id: ON_TIME, status: 'SUCCESS', value: 5 }]; - const len = OnOffCluster.attributeArrayStatusDataType.toBuffer(buf, records, 0); - return { attributes: buf.subarray(0, len) }; - } -})(); -const { zclNode } = createVirtualDevice({ - endpoints: [{ endpointId: 1, inputClusters: [OnOffCluster.ID], clusters: { onOff } }], -}); -``` +## Recipes -**A device that never answers.** Drop its frames and advance the response timeout with mock timers: +**A device that never answers:** ```js t.mock.timers.enable({ apis: ['setTimeout'] }); @@ -114,8 +62,7 @@ t.mock.timers.tick(10_000); await assert.rejects(read, /Timeout/); ``` -**Duplicate, colliding or rewritten frames.** Return more than one buffer from `intercept`. To have -the device send an unrelated frame with the pending TSN right before its reply: +**A frame reusing the pending TSN, before the real reply:** ```js intercept(({ from, frame, raw }) => @@ -123,41 +70,27 @@ intercept(({ from, frame, raw }) => ); ``` -**Something the device starts, such as a report or a notification.** Build the frame with -`frameToClient()`, send it with `virtualDevice.sendFrame()`, then `await settle()` before -asserting. See `test/attributeReport.js`. - -**Homey serving the device.** For traffic the device starts, such as OTA requests or a remote's -commands, give the device the cluster as an output cluster, bind the `BoundCluster` on `zclNode` -and send from `virtualDevice.endpoints[n].clusters`. See `test/ota.js`. +**Homey serving the device** (OTA, a remote's commands): put the cluster in the device's +`outputClusters`, bind on `zclNode`, send from `virtualDevice.endpoints[n].clusters`. See +`test/ota.js`. -**A known bug.** Write the test for the correct behavior and mark it -`{ todo: 'what is wrong, or the fixing PR' }`. It runs and reports, but does not fail the suite. The -fix removes the `todo`. Check in the output that a todo test fails on the bug itself and not on a -mistake in the test, because nothing else will tell you. See `test/virtualDevice.js`. +**A known bug:** a test for the correct behavior marked `{ todo: '...' }`; the fix removes the +`todo`. Check it fails on the bug, not on the test. See `test/virtualDevice.js`. ## Rules -- **Never assert inside a handler.** An error thrown in a `BoundCluster` or cluster handler is - caught by the endpoint and turned into an error Default Response, so the assertion message is - lost or the test passes anyway. Store what the handler received and assert after the `await`. -- **No `done` callbacks.** Await the command, or `settle()`, then assert. A `done` that is never - called fails only after `npm test`'s 5 second timeout, and hangs a bare `node --test`. -- **`await settle()` before asserting that something did not happen**, and before asserting on - anything a device-initiated frame triggers. Awaiting a command only waits for its own response. -- **Fake time, never wait.** Use `t.mock.timers` for response timeouts, which default to 10 seconds. -- **One device per test.** Create it inside the `it`, so no test depends on what an earlier one - bound. -- **Assert specific errors.** `assert.rejects(promise, { message: 'UNSUPPORTED_CLUSTER' })`, not - just that something threw. -- **Prove the failure path.** Break the library code the test is about and check the test fails. +- **Never assert inside a handler.** The endpoint turns the thrown assertion into an error Default + Response. Store what the handler got, assert after the `await`. +- **No `done` callbacks.** Await the command or `settle()`. +- **`await settle()`** before asserting that something did not happen, or on what a frame from the + device triggered. +- **Fake time** with `t.mock.timers`; response timeouts default to 10 seconds. +- **One device per test**, created inside the `it`. +- **Assert specific errors and real bytes.** A round trip through the same definition passes when + the definition is wrong; compare `frames[i].raw` against the spec, as `test/colorControl.js` does. +- **Prove the failure path.** Break the library code under test and check the test fails. ## Known gaps -- The device has no typed way to send reports or server-to-client commands on its input clusters: - `frameToClient()` builds the header, the payload bytes are written by hand. -- Interceptors run synchronously before delivery, so "deliver, then have `sendFrame` reject" or - "resolve `sendFrame` late" need a stub on top of the harness, see - `test/testClusterResponseTimeout.js`. -- `test/endpointStandalone.js` builds an `Endpoint` without a node on purpose and does not use the - harness. +Interceptors run before delivery, so "deliver, then reject `sendFrame`" or "resolve `sendFrame` +late" need a stub on top, see `test/testClusterResponseTimeout.js`. From b8b500203a490aef1ddfea2762281169995308bc Mon Sep 17 00:00:00 2001 From: Robin Bolscher Date: Tue, 27 Oct 2026 13:35:34 +0100 Subject: [PATCH 8/8] docs: Explain the test harness as a concept and point to examples Option and return value details now live as JSDoc on createVirtualDevice, next to the code they describe, so they cannot drift from it. The guide keeps the idea, a table of example tests per scenario, and the rules. --- docs/testing.md | 112 +++++++++++-------------------------- test/util/virtualDevice.js | 13 ++++- 2 files changed, 44 insertions(+), 81 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 1c503e9c..672edeab 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,96 +1,48 @@ # Writing tests -Every test that needs a node uses `createVirtualDevice()` from `test/util/virtualDevice.js`. It puts -the code under test and a scripted device on two nodes, linked in memory. No Homey, no radio. +## The idea -- `zclNode` is the node under test, a `ZCLNode` as a driver gets it. Its `endpoints[n].clusters` - talk to the device; `endpoints[n].bind()` serves what the device sends, such as OTA requests. -- `virtualDevice` is a second `ZCLNode` playing the device: `BoundCluster`s for its input clusters, - `clusters` for its output clusters. +A test plays both sides of a Zigbee conversation. `createVirtualDevice()` in +[`test/util/virtualDevice.js`](../test/util/virtualDevice.js) creates two nodes linked in memory: -Both run the library's real encoding, parsing and response matching. As on Homey, `sendFrame` -resolves before the other side sees the frame, so response timeouts run. +- `zclNode`, the code under test, as a Homey driver gets it. +- `virtualDevice`, the device it talks to, scripted by the test with `BoundCluster`s. -```js -const { createVirtualDevice } = require('./util/virtualDevice'); +Both run the library's real encoding, parsing and response matching; only the radio is replaced. +The link behaves like Homey's stack where it matters for this library: frames arrive after +`sendFrame` resolves, so response timeouts run, and `zclNode` cannot send more than 82 bytes. The +link also logs every frame and lets a test drop, rewrite or inject frames, which is how a test makes +a device misbehave in ways real hardware rarely does. -it('reads the on/off state', async function () { - const { zclNode } = createVirtualDevice({ - endpoints: [ - { endpointId: 1, inputClusters: [OnOffCluster.ID], clusters: { onOff: { onOff: true } } }, - ], - }); +The options and return values are documented on the functions in +[`test/util/virtualDevice.js`](../test/util/virtualDevice.js). - const result = await zclNode.endpoints[1].clusters.onOff.readAttributes(['onOff']); +## Examples - assert.deepStrictEqual(result, { onOff: true }); -}); -``` - -## Reference - -`createVirtualDevice({ endpoints, maxFrameSize })`: - -- `endpoints[]`: `endpointId`, `inputClusters`, `outputClusters` as the device's descriptors, and - `clusters` keyed by name: attribute values (readable and writable) or your own `BoundCluster`. -- `maxFrameSize`: longest frame `zclNode` may send, default 82 bytes as Homey's stack. - -It returns: - -- `zclNode`, `virtualDevice`. `virtualDevice.sendFrame(endpointId, clusterId, buffer, meta)` sends a - raw frame; `meta` is what the stack passes, such as `{ groupId }` or `{ broadcast: true }`. -- `frames`: every frame sent, as `{ from, endpointId, clusterId, frame, raw }`, where `from` is - `'zclNode'` or `'virtualDevice'` and `frame` the parsed header. -- `intercept(fn)`: `fn(record)` returns `undefined` to pass a frame, `null` to drop it, or buffers to - deliver instead. Throwing makes the sender's `sendFrame` reject. -- `settle()`: waits until no frame is in flight. Rejects when a delivery failed, or after 1000 - frames, which means the nodes are answering each other in a loop. - -Also exported: `createSingleClusterDevice(OnOffCluster, boundCluster)` for one endpoint with one -cluster, and `frameToClient({ cmdId, data, clusterSpecific, ... })` for a report or notification. - -## Recipes - -**A device that never answers:** - -```js -t.mock.timers.enable({ apis: ['setTimeout'] }); -intercept(({ from }) => (from === 'virtualDevice' ? null : undefined)); -const read = zclNode.endpoints[1].clusters.onOff.readAttributes(['onOff']); -await settle(); -t.mock.timers.tick(10_000); -await assert.rejects(read, /Timeout/); -``` - -**A frame reusing the pending TSN, before the real reply:** - -```js -intercept(({ from, frame, raw }) => - from === 'virtualDevice' ? [reportWithTsn(frame.trxSequenceNumber), raw] : undefined, -); -``` - -**Homey serving the device** (OTA, a remote's commands): put the cluster in the device's -`outputClusters`, bind on `zclNode`, send from `virtualDevice.endpoints[n].clusters`. See -`test/ota.js`. - -**A known bug:** a test for the correct behavior marked `{ todo: '...' }`; the fix removes the -`todo`. Check it fails on the bug, not on the test. See `test/virtualDevice.js`. +| To test | See | +| ----------------------------------------------------------- | ------------------------------------ | +| A command or attribute read | `test/onOff.js` | +| The bytes on the wire against the specification | `test/colorControl.js` | +| A report or notification from the device | `test/attributeReport.js` | +| A device that answers wrongly, or never; known bugs as todo | `test/virtualDevice.js` | +| Colliding TSNs, duplicate responses, failed sends | `test/testClusterResponseTimeout.js` | +| Group and broadcast frames | `test/testNode.js` | +| Homey serving the device, such as OTA | `test/ota.js` | +| A cluster with its own data type | `test/xiaomiLifeline.js` | ## Rules -- **Never assert inside a handler.** The endpoint turns the thrown assertion into an error Default - Response. Store what the handler got, assert after the `await`. -- **No `done` callbacks.** Await the command or `settle()`. +A test is only worth having if it fails when the code it covers breaks. + +- **Never assert inside a handler.** The endpoint turns a thrown assertion into an error Default + Response, so the test may pass anyway. Store what the handler got and assert after the `await`. +- **No `done` callbacks.** Await the command, or `settle()`. - **`await settle()`** before asserting that something did not happen, or on what a frame from the device triggered. -- **Fake time** with `t.mock.timers`; response timeouts default to 10 seconds. +- **Fake time** with `t.mock.timers`, never wait for a real timeout. - **One device per test**, created inside the `it`. - **Assert specific errors and real bytes.** A round trip through the same definition passes when - the definition is wrong; compare `frames[i].raw` against the spec, as `test/colorControl.js` does. + the definition is wrong. - **Prove the failure path.** Break the library code under test and check the test fails. - -## Known gaps - -Interceptors run before delivery, so "deliver, then reject `sendFrame`" or "resolve `sendFrame` -late" need a stub on top, see `test/testClusterResponseTimeout.js`. +- **A known bug** is a test for the correct behavior marked `{ todo: '...' }`. Check it fails on the + bug, not on the test; the fix removes the `todo`. diff --git a/test/util/virtualDevice.js b/test/util/virtualDevice.js index 5908b1f5..db2b5523 100644 --- a/test/util/virtualDevice.js +++ b/test/util/virtualDevice.js @@ -34,7 +34,18 @@ function boundClusterWithAttributes(attributes) { /** * Creates the node under test (`zclNode`) and a scripted device (`virtualDevice`), linked in - * memory. See docs/testing.md for the options, what it returns and how to use it. + * memory. docs/testing.md explains the idea. + * + * @param {object} config + * @param {object[]} config.endpoints - The device's endpoints as its descriptors list them + * (`endpointId`, `inputClusters`, `outputClusters`), plus `clusters`: per cluster name, attribute + * values or a `BoundCluster` that serves it. + * @param {number} [config.maxFrameSize] - Longest frame `zclNode` may send. + * @returns {object} `zclNode` and `virtualDevice`; `frames`, every frame sent as `{ from, + * endpointId, clusterId, frame, raw }`; `intercept(fn)`, where `fn(record)` returns `undefined` + * to pass a frame, `null` to drop it or buffers to send instead, and throwing fails the sender's + * `sendFrame`; `settle()`, which waits until no frame is in flight and rejects when a delivery + * failed. */ function createVirtualDevice({ endpoints, maxFrameSize = HOMEY_MAX_ZCL_BYTES }) { const frames = [];