test: split the 1.5.2 suite into a harness and per-topic files

test/helpers/harness.js holds the aedes broker, the Alexa-side watcher, until() and the cleanups, plus directive(),
connected() and setup(), which the old file spelled out in each test. test/alex2node.test.js (194 lines, 8 tests)
becomes connection, discovery, directives, change-report and typings.

The 50-line round-trip test is cut at its topic boundaries and the duplicate-endpointId test no longer carries the
thermostat discovery check, so a failure names a case: 8 tests become 15, every 1.5.2 assertion kept. Four checks
are stricter: the "directive" event is compared whole, the StateReport is checked for its token and properties, the
Celsius conversion covers lowerSetpoint as well as temperature, and a duplicate endpointId is listed once.

No change to src/ or dist/. npm test: 15 pass in 0.9 s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
David 2026-09-28 13:45:38 +00:00
parent 275f00f8a7
commit 58869d44c1
7 changed files with 447 additions and 194 deletions

105
test/helpers/harness.js Normal file
View file

@ -0,0 +1,105 @@
"use strict";
// Shared by every test file: an in-process MQTT broker (aedes), a client that plays the Alexa side of the Alex2MQTT
// topic contract, and until() for polling. Everything listens on 127.0.0.1, on a port the OS picks. node --test runs
// each file in its own process, so the cleanups registered here close that file's brokers and clients when it ends.
const { after } = require("node:test");
const net = require("node:net");
const aedes = require("aedes");
const mqtt = require("mqtt");
const cleanups = [];
// Newest first: a client closes before the broker it is connected to.
after(async () => {
for (const close of cleanups.reverse()) {
try { await close(); } catch { /* already closed */ }
}
});
/** Run fn when the test file ends. */
function cleanup(fn) {
cleanups.push(fn);
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** Poll fn every 15 ms until it returns something truthy. Throws "timeout waiting for <what>" after ms. */
async function until(fn, ms = 3000, what = "condition") {
const start = Date.now();
while (Date.now() - start < ms) {
if (await fn()) return true;
await sleep(15);
}
throw new Error(`timeout waiting for ${what}`);
}
/** An MQTT broker on 127.0.0.1. */
async function broker() {
const instance = aedes();
const server = net.createServer(instance.handle);
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
cleanup(() => new Promise((resolve) => instance.close(() => server.close(() => resolve()))));
const { port } = server.address();
return { aedes: instance, port, url: `mqtt://127.0.0.1:${port}` };
}
/**
* The Alexa side (what Alex2MQTT does on the broker): a client subscribed to filter that keeps every message it
* receives, in order, as { topic, payload } - the payload parsed when it is JSON, the text otherwise.
*/
async function watcher(url, filter) {
const client = mqtt.connect(url);
cleanup(() => new Promise((resolve) => client.end(true, {}, resolve)));
await new Promise((resolve, reject) => { client.once("connect", resolve); client.once("error", reject); });
const got = [];
client.on("message", (topic, message) => {
let payload = message.toString();
try { payload = JSON.parse(payload); } catch { /* not JSON: kept as text */ }
got.push({ topic, payload });
});
await new Promise((resolve, reject) => client.subscribe(filter, (err) => (err ? reject(err) : resolve())));
const publish = (topic, payload) => client.publish(topic, typeof payload === "string" ? payload : JSON.stringify(payload));
return {
client,
got,
publish,
/** The payloads received on one topic, oldest first. */
on: (topic) => got.filter((m) => m.topic === topic).map((m) => m.payload),
/** Publish a directive where Alex2MQTT does: <root>/<endpointId>/alexaDirective. */
send: (root, message) => publish(`${root}/${message.endpoint.endpointId}/alexaDirective`, message),
};
}
/** A directive as Alex2MQTT publishes it: the Alexa directive with endpoint.scope (the user's token) removed. */
function directive(namespace, name, endpointId, correlationToken, payload = {}) {
return {
header: { namespace, name, payloadVersion: "3", messageId: `message-${correlationToken}`, correlationToken },
endpoint: { endpointId },
payload,
};
}
/**
* connect() the bridge and wait for its "connect" event, which fires once it is subscribed - a directive published
* earlier would be lost. The bridge is disconnected when the test file ends.
*/
async function connected(bridge, ms = 3000) {
let subscribed = false;
bridge.once("connect", () => { subscribed = true; });
bridge.connect();
cleanup(() => bridge.disconnect());
await until(() => subscribed, ms, "the bridge to connect and subscribe");
return bridge;
}
/**
* A broker, the Alexa side subscribed to <root>/#, and a connected bridge. The Alex2MQTT class is passed in: the test
* file decides which build of the library it loads, not the harness.
*/
async function setup(Alex2MQTT, root, options = {}) {
const { url } = await broker();
const alexa = await watcher(url, `${root}/#`);
const bridge = await connected(new Alex2MQTT("u", "p", root, false, { host: url, ...options }));
return { url, alexa, bridge };
}
module.exports = { broker, watcher, directive, connected, setup, until, sleep, cleanup };