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

View file

@ -1,194 +0,0 @@
"use strict";
// Alex2Node against an in-process MQTT broker (aedes): the 1.4.0 contract (discovery on <root>/discover -> <root>/discover_r,
// directives on <root>/<endpoint>/alexaDirective -> Response on .../alexaResponce) and the 1.5.1 additions: a configurable
// broker, connection errors as events that never crash the host, ChangeReport on <root>/changeReport, scene responses,
// unregister.
const { test, after } = require("node:test");
const assert = require("node:assert/strict");
const net = require("node:net");
const aedes = require("aedes");
const mqtt = require("mqtt");
const fs = require("node:fs");
const path = require("node:path");
const { Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController, EndpointHealth, TemperatureSensorScale, DEFAULT_HOST, ActionMapping, AlexaActions, AlexaStatusMessage } = require("../dist/index.js");
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 3000, what = "condition") => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await fn()) return true; await sleep(15); } throw new Error(`timeout waiting for ${what}`); };
const cleanups = [];
after(async () => { for (const c of cleanups.reverse()) { try { await c(); } catch { /* gone */ } } });
async function broker() {
const a = aedes();
const server = net.createServer(a.handle);
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const port = server.address().port;
cleanups.push(() => new Promise((r) => { a.close(() => server.close(() => r())); }));
return { a, port, url: `mqtt://127.0.0.1:${port}` };
}
async function watcher(url, filter) {
const c = mqtt.connect(url); const got = [];
await new Promise((r, j) => { c.once("connect", r); c.once("error", j); });
await new Promise((r) => c.subscribe(filter, r));
c.on("message", (t, m) => { let p; try { p = JSON.parse(m.toString()); } catch { p = m.toString(); } got.push({ topic: t, payload: p }); });
cleanups.push(() => new Promise((r) => c.end(true, {}, r)));
return { c, got, publish: (t, p) => c.publish(t, typeof p === "string" ? p : JSON.stringify(p)) };
}
test("defaults: the 1.4.0 host stays the default; options.host overrides it", () => {
const a = new Alex2MQTT("u", "p", "root");
assert.equal(a.getHost(), DEFAULT_HOST); assert.equal(DEFAULT_HOST, "mqtt://Alex2MQTT.stormysdream.club:1883");
assert.equal(new Alex2MQTT("u", "p", "root", false, { host: "mqtt://127.0.0.1:1" }).getHost(), "mqtt://127.0.0.1:1");
});
test("discovery + directive + ReportState round trip on a local broker", async () => {
const b = await broker();
const alexa = await watcher(b.url, "root/#");
const bridge = new Alex2MQTT("u", "p", "root", false, { host: b.url });
const events = []; bridge.on("discover", (n) => events.push(["discover", n])); bridge.on("directive", (d) => events.push(["directive", d.name]));
bridge.connect();
await until(() => bridge.connected, 3000, "connect");
cleanups.push(() => bridge.disconnect());
const lamp = bridge.registerDevice("Dining Room Light", "5020AA", DisplayCategory.LIGHT);
lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER, { proactivelyReported: true });
lamp.addCapability(AlexaInterfaceType.BRIGHTNESS_CONTROLLER);
const scene = bridge.registerDevice("Evening", "scene-1", DisplayCategory.SCENE_TRIGGER);
scene.addCapability(AlexaInterfaceType.SCENE_CONTROLLER);
lamp.on("Event", (directive, ns) => { lamp.getStatusMessage(directive.header.correlationToken, true).addHealthProp(EndpointHealth.OK).addPowerControllerProp(directive.header.name === "TurnOn" ? PowerController.ON : PowerController.OFF).addBrightnessControllerProp(100).send(false); });
lamp.on("ReportState", (payload) => { lamp.getStatusMessage(payload.header.correlationToken).addPowerControllerProp(PowerController.OFF).addBrightnessControllerProp(0).send(false); });
scene.on("Event", (directive) => { scene.sendSceneResponse(directive.header.correlationToken, directive.header.name === "Activate"); });
alexa.publish("root/discover", { namespace: "Alexa.Discovery", name: "Discover" });
await until(() => alexa.got.some((m) => m.topic === "root/discover_r"), 3000, "discovery reply");
const disc = alexa.got.find((m) => m.topic === "root/discover_r").payload;
assert.equal(disc.length, 2);
assert.equal(disc[0].friendlyName, "Dining Room Light"); assert.deepEqual(disc[0].displayCategories, ["LIGHT"]);
const power = disc[0].capabilities.find((c) => c.interface === "Alexa.PowerController"); assert.equal(power.properties.proactivelyReported, true); assert.deepEqual(power.properties.supported, [{ name: "powerState" }]);
const sc = disc[1].capabilities.find((c) => c.interface === "Alexa.SceneController"); assert.equal(sc.supportsDeactivation, true); assert.equal(sc.version, "3"); assert.equal(sc.properties, undefined);
assert.deepEqual(events[0], ["discover", 2]); assert.equal(bridge.lastDiscoveryAt !== null, true);
alexa.publish("root/5020AA/alexaDirective", { header: { namespace: "Alexa.PowerController", name: "TurnOn", correlationToken: "ct1", payloadVersion: "3", messageId: "m1" }, endpoint: { endpointId: "5020AA" }, payload: {} });
await until(() => alexa.got.some((m) => m.topic === "root/5020AA/alexaResponce"), 3000, "directive response");
const resp = alexa.got.find((m) => m.topic === "root/5020AA/alexaResponce").payload;
assert.equal(resp.event.header.name, "Response"); assert.equal(resp.event.header.correlationToken, "ct1"); assert.equal(resp.context.properties.find((p) => p.name === "powerState").value, "ON");
assert.deepEqual(events[1], ["directive", "TurnOn"]);
alexa.publish("root/5020AA/alexaDirective", { header: { namespace: "Alexa", name: "ReportState", correlationToken: "ct2", payloadVersion: "3", messageId: "m2" }, endpoint: { endpointId: "5020AA" }, payload: {} });
await until(() => alexa.got.filter((m) => m.topic === "root/5020AA/alexaResponce").length === 2, 3000, "state report");
assert.equal(alexa.got.filter((m) => m.topic === "root/5020AA/alexaResponce")[1].payload.event.header.name, "StateReport");
alexa.publish("root/scene-1/alexaDirective", { header: { namespace: "Alexa.SceneController", name: "Activate", correlationToken: "ct3", payloadVersion: "3", messageId: "m3" }, endpoint: { endpointId: "scene-1" }, payload: {} });
await until(() => alexa.got.some((m) => m.topic === "root/scene-1/alexaResponce"), 3000, "scene response");
const sr = alexa.got.find((m) => m.topic === "root/scene-1/alexaResponce").payload;
assert.equal(sr.event.header.namespace, "Alexa.SceneController"); assert.equal(sr.event.header.name, "ActivationStarted"); assert.equal(sr.event.payload.cause.type, "VOICE_INTERACTION"); assert.ok(sr.event.payload.timestamp);
// change report: changed props in payload.change, the rest in context, on <root>/changeReport
const topic = await lamp.getChangeReport("PHYSICAL_INTERACTION").addPowerControllerProp(PowerController.OFF).unchanged().addBrightnessControllerProp(0).send();
assert.equal(topic, "root/changeReport");
await until(() => alexa.got.some((m) => m.topic === "root/changeReport"), 3000, "change report");
const cr = alexa.got.find((m) => m.topic === "root/changeReport").payload;
assert.equal(cr.event.header.name, "ChangeReport"); assert.equal(cr.event.endpoint.endpointId, "5020AA"); assert.equal(cr.event.payload.change.cause.type, "PHYSICAL_INTERACTION");
assert.deepEqual(cr.event.payload.change.properties.map((p) => [p.name, p.value]), [["powerState", "OFF"]]); assert.deepEqual(cr.context.properties.map((p) => [p.name, p.value]), [["brightness", 0]]);
// thermostat props convert to celsius as 1.4.0 did
const st = lamp.getStatusMessage("x").addTemperatureSensorProp(TemperatureSensorScale.FAHRENHEIT, 68).addThermostatControllerProp("lowerSetpoint", TemperatureSensorScale.FAHRENHEIT, 68).toJSON();
assert.equal(Math.round(st.context.properties[0].value.value * 10) / 10, 20); assert.equal(st.context.properties[0].value.scale, "CELSIUS");
// unregister: the device no longer answers
assert.equal(bridge.unregisterDevice("scene-1"), true); assert.equal(bridge.unregisterDevice("scene-1"), false); assert.equal(bridge.getDevices().length, 1);
alexa.publish("root/discover", {});
await until(() => alexa.got.filter((m) => m.topic === "root/discover_r").length === 2, 3000, "second discovery");
assert.equal(alexa.got.filter((m) => m.topic === "root/discover_r")[1].payload.length, 1);
await bridge.disconnect(); assert.equal(bridge.connected, false);
});
test("a dead broker: 'offline'/'error' events when listened to, and NO crash when nobody listens (1.4.0 killed the host)", async () => {
const dead = new Alex2MQTT("u", "p", "root", false, { host: "mqtt://127.0.0.1:1", mqtt: { reconnectPeriod: 50, connectTimeout: 200 } });
dead.connect(); cleanups.push(() => dead.disconnect());
await sleep(300);
assert.equal(dead.connected, false, "still alive, not connected");
const seen = [];
const listened = new Alex2MQTT("u", "p", "root", false, { host: "mqtt://127.0.0.1:1", mqtt: { reconnectPeriod: 50, connectTimeout: 200 } });
listened.on("error", (e) => seen.push(e.code || e.message)); listened.on("offline", () => seen.push("offline")); listened.on("reconnect", () => seen.push("reconnect"));
listened.connect(); cleanups.push(() => listened.disconnect());
await until(() => seen.includes("ECONNREFUSED") && seen.includes("reconnect"), 3000, "error + reconnect events");
});
test("registerDevice before connect throws (unchanged from 1.4.0)", () => {
assert.throws(() => new Alex2MQTT("u", "p", "root").registerDevice("x", "1", null), /connect before/);
});
// 1.5.2
const connected = (bridge) => { const p = new Promise((r) => bridge.once("connect", r)); bridge.connect(); return p; }; // resolves once subscribed to <root>/#
const turnOn = (alexa, id, ct) => alexa.publish(`root/${id}/alexaDirective`, { header: { namespace: "Alexa.PowerController", name: "TurnOn", correlationToken: ct, payloadVersion: "3", messageId: ct }, endpoint: { endpointId: id }, payload: {} });
const answered = (alexa, id, ct) => alexa.got.some((m) => m.topic === `root/${id}/alexaResponce` && m.payload.event.header.correlationToken === ct);
test("disconnect() then connect(): devices registered before still answer (1.5.1 left them on the closed client)", async () => {
const b = await broker();
const alexa = await watcher(b.url, "root/#");
const bridge = new Alex2MQTT("u", "p", "root", false, { host: b.url });
await connected(bridge);
cleanups.push(() => bridge.disconnect());
const lamp = bridge.registerDevice("Lamp", "lamp-1", DisplayCategory.LIGHT);
lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER);
lamp.on("Event", (d) => { lamp.getStatusMessage(d.header.correlationToken, true).addPowerControllerProp(PowerController.ON).send(); });
turnOn(alexa, "lamp-1", "before");
await until(() => answered(alexa, "lamp-1", "before"), 3000, "response before disconnect");
await bridge.disconnect(); assert.equal(bridge.connected, false);
await connected(bridge);
assert.equal(bridge.getDevices().length, 1, "the devices stay registered");
turnOn(alexa, "lamp-1", "after");
await until(() => answered(alexa, "lamp-1", "after"), 3000, "response after disconnect() + connect()");
});
test("registerDevice with an endpointId already registered returns the existing device and warns; thermostat discovery lists targetSetpoint", async () => {
const b = await broker();
const alexa = await watcher(b.url, "root/#");
const logs = [];
const bridge = new Alex2MQTT("u", "p", "root", false, { host: b.url, log: (m) => logs.push(m) });
await connected(bridge);
cleanups.push(() => bridge.disconnect());
const a = bridge.registerDevice("Lamp A", "same", DisplayCategory.LIGHT); a.addCapability(AlexaInterfaceType.POWER_CONTROLLER);
const again = bridge.registerDevice("Lamp B", "same", DisplayCategory.SWITCH);
assert.equal(again, a); assert.equal(again.name, "Lamp A"); assert.equal(bridge.getDevices().length, 1);
assert.ok(logs.some((m) => /warning: registerDevice\("same"\)/.test(m)), "warned through the log hook");
// without a log hook (the examples and the Quick Start set none) the warning falls back to console.warn
const quiet = new Alex2MQTT("u", "p", "root-quiet", false, { host: b.url }); quiet.connect(); cleanups.push(() => quiet.disconnect());
const warned = []; const realWarn = console.warn; console.warn = (...args) => warned.push(args.join(" "));
try { quiet.registerDevice("Q", "same", null); assert.equal(quiet.registerDevice("Q again", "same", null).name, "Q"); } finally { console.warn = realWarn; }
assert.ok(warned.some((m) => /warning: registerDevice\("same"\)/.test(m)), "console.warn without a log hook");
bridge.registerDevice("Thermostat", "thermo-1", DisplayCategory.THERMOSTAT).addCapability(AlexaInterfaceType.THERMOSTAT_CONTROLLER);
alexa.publish("root/discover", {});
await until(() => alexa.got.some((m) => m.topic === "root/discover_r"), 3000, "discovery");
const disc = alexa.got.find((m) => m.topic === "root/discover_r").payload;
assert.deepEqual(disc.map((d) => d.endpointId), ["same", "thermo-1"]);
const thermo = disc[1].capabilities.find((c) => c.interface === "Alexa.ThermostatController");
assert.deepEqual(thermo.properties.supported.map((p) => p.name), ["targetSetpoint", "lowerSetpoint", "upperSetpoint", "thermostatMode"]);
assert.equal(thermo.version, "3.2");
});
test("1.5.2 typings: dist ships declarations, ActionMapping's payload is optional, addHealthProp takes the enum or the string", () => {
for (const f of ["index", "Alex2Node", "Device", "AlexaInterface", "AlexaStatusMessage", "AlexaErrorResponse", "ActionMapping", "DisplayCategory"]) assert.ok(fs.existsSync(path.join(__dirname, "..", "dist", `${f}.d.ts`)), `dist/${f}.d.ts`);
// the declarations themselves (N-15 / NX-04 / N-04): a revert in src/ would rebuild narrower types and the runtime checks below would not notice
const dts = (f) => fs.readFileSync(path.join(__dirname, "..", "dist", `${f}.d.ts`), "utf8");
assert.match(dts("ActionMapping"), /constructor\(actions: AlexaActions\[\], directiveName: string, directivePayload\?: string\);/);
assert.match(dts("AlexaStatusMessage"), /addHealthProp\(health: EndpointHealth \| `\$\{EndpointHealth\}`, uncertaintyInMs\?: number\): this;/);
assert.match(dts("AlexaInterface"), /addSupportedModes\(modes: Array<string \| SupportedMode>\): void;/);
assert.match(dts("index"), /export type \{ SupportedMode \} from "\.\/AlexaInterface";/);
assert.deepEqual(new ActionMapping([AlexaActions.Close], "TurnOn").toJSON(), { "@type": "ActionsToDirective", actions: ["Alexa.Actions.Close"], directive: { name: "TurnOn" } });
const health = (h) => new AlexaStatusMessage("ct", "root", "e", null).addHealthProp(h).toJSON().context.properties[0].value;
assert.deepEqual(health(EndpointHealth.OK), { value: "OK" }); assert.deepEqual(health("UNREACHABLE"), { value: "UNREACHABLE" });
});
test("1.5.2: a failed publish never rejects send() - it resolves \"\" and reaches the bridge's error listener", async () => {
const b = await broker();
const bridge = new Alex2MQTT("u", "p", "root-send", false, { host: b.url });
const errors = []; bridge.on("error", (e) => errors.push(e.message));
bridge.connect(); cleanups.push(() => bridge.disconnect());
await new Promise((r) => bridge.once("connect", r));
const lamp = bridge.registerDevice("Lamp", "lamp-send", null); lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER);
await bridge.disconnect(); // the device still holds the ended client until the next connect(): every publish now fails
const status = await lamp.getStatusMessage("ct", true).addPowerControllerProp(PowerController.ON).send();
const error = await lamp.getErrorMessage("ct").send();
const scene = await lamp.sendSceneResponse("ct", true);
const change = await lamp.getChangeReport("PHYSICAL_INTERACTION").addPowerControllerProp(PowerController.OFF).send();
assert.deepEqual([status, error, scene, change], ["", "", "", ""], "each resolves \"\" instead of rejecting");
assert.ok(errors.length >= 4, `every failure reached the error listener: ${errors.join(" | ")}`);
// and with NO listener the same failures are swallowed (an "error" event without a listener would throw)
const quiet = new Alex2MQTT("u", "p", "root-send-quiet", false, { host: b.url }); quiet.connect(); cleanups.push(() => quiet.disconnect());
await new Promise((r) => quiet.once("connect", r));
const q = quiet.registerDevice("Q", "q-1", null); await quiet.disconnect();
assert.equal(await q.getStatusMessage("ct", true).addPowerControllerProp(PowerController.ON).send(), "");
});

View file

@ -0,0 +1,29 @@
"use strict";
// Alexa.ChangeReport: published on <root>/changeReport, where Alex2MQTT adds the user's token and posts it to the
// Alexa event gateway.
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController } = require("../dist/index.js");
const { setup, until } = require("./helpers/harness.js");
test("a change report carries the changed properties in payload.change and the rest in context", async () => {
const { alexa, bridge } = await setup(Alex2MQTT, "root");
const lamp = bridge.registerDevice("Dining Room Light", "5020AA", DisplayCategory.LIGHT);
lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER, { proactivelyReported: true });
lamp.addCapability(AlexaInterfaceType.BRIGHTNESS_CONTROLLER);
const topic = await lamp.getChangeReport("PHYSICAL_INTERACTION")
.addPowerControllerProp(PowerController.OFF)
.unchanged()
.addBrightnessControllerProp(0)
.send();
assert.equal(topic, "root/changeReport");
await until(() => alexa.on("root/changeReport").length === 1, 3000, "change report");
const [report] = alexa.on("root/changeReport");
assert.equal(report.event.header.name, "ChangeReport");
assert.equal(report.event.endpoint.endpointId, "5020AA");
assert.equal(report.event.payload.change.cause.type, "PHYSICAL_INTERACTION");
assert.deepEqual(report.event.payload.change.properties.map((p) => [p.name, p.value]), [["powerState", "OFF"]]);
assert.deepEqual(report.context.properties.map((p) => [p.name, p.value]), [["brightness", 0]]);
});

83
test/connection.test.js Normal file
View file

@ -0,0 +1,83 @@
"use strict";
// The broker connection: the default host, an outage reported as events that never crash the host, disconnect() then
// connect(), and a publish that fails without rejecting.
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController, DEFAULT_HOST } = require("../dist/index.js");
const { broker, setup, connected, directive, until, sleep, cleanup } = require("./helpers/harness.js");
// Nothing listens on port 1, so every attempt is refused at once.
const DEAD_BROKER = { host: "mqtt://127.0.0.1:1", mqtt: { reconnectPeriod: 50, connectTimeout: 200 } };
test("defaults: the 1.4.0 host stays the default; options.host overrides it", () => {
assert.equal(DEFAULT_HOST, "mqtt://Alex2MQTT.stormysdream.club:1883");
assert.equal(new Alex2MQTT("u", "p", "root").getHost(), DEFAULT_HOST);
assert.equal(new Alex2MQTT("u", "p", "root", false, { host: "mqtt://127.0.0.1:1" }).getHost(), "mqtt://127.0.0.1:1");
});
test("a dead broker: 'offline'/'error' events when listened to, and NO crash when nobody listens (1.4.0 killed the host)", async () => {
const unheard = new Alex2MQTT("u", "p", "root", false, DEAD_BROKER);
unheard.connect();
cleanup(() => unheard.disconnect());
await sleep(300);
assert.equal(unheard.connected, false, "still alive, not connected");
const seen = [];
const heard = new Alex2MQTT("u", "p", "root", false, DEAD_BROKER);
heard.on("error", (e) => seen.push(e.code || e.message));
heard.on("offline", () => seen.push("offline"));
heard.on("reconnect", () => seen.push("reconnect"));
heard.connect();
cleanup(() => heard.disconnect());
await until(() => seen.includes("ECONNREFUSED") && seen.includes("reconnect"), 3000, "error + reconnect events");
});
test("registerDevice before connect throws (unchanged from 1.4.0)", () => {
assert.throws(() => new Alex2MQTT("u", "p", "root").registerDevice("x", "1", null), /connect before/);
});
test("disconnect() then connect(): devices registered before still answer (1.5.1 left them on the closed client)", async () => {
const { alexa, bridge } = await setup(Alex2MQTT, "root");
const lamp = bridge.registerDevice("Lamp", "lamp-1", DisplayCategory.LIGHT);
lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER);
lamp.on("Event", (d) => {
lamp.getStatusMessage(d.header.correlationToken, true).addPowerControllerProp(PowerController.ON).send();
});
const turnOn = (token) => alexa.send("root", directive("Alexa.PowerController", "TurnOn", "lamp-1", token));
const tokens = () => alexa.on("root/lamp-1/alexaResponce").map((m) => m.event.header.correlationToken);
const answered = (token) => tokens().includes(token);
turnOn("before");
await until(() => answered("before"), 3000, "response before disconnect");
await bridge.disconnect();
assert.equal(bridge.connected, false);
await connected(bridge);
assert.equal(bridge.getDevices().length, 1, "the devices stay registered");
turnOn("after");
await until(() => answered("after"), 3000, "response after disconnect() + connect()");
});
test("1.5.2: a failed publish never rejects send() - it resolves \"\" and reaches the bridge's error listener", async () => {
const { url } = await broker();
const bridge = new Alex2MQTT("u", "p", "root-send", false, { host: url });
const errors = [];
bridge.on("error", (e) => errors.push(e.message));
await connected(bridge);
const lamp = bridge.registerDevice("Lamp", "lamp-send", null);
lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER);
await bridge.disconnect(); // the device still holds the ended client until the next connect(): every publish now fails
const status = await lamp.getStatusMessage("ct", true).addPowerControllerProp(PowerController.ON).send();
const error = await lamp.getErrorMessage("ct").send();
const scene = await lamp.sendSceneResponse("ct", true);
const change = await lamp.getChangeReport("PHYSICAL_INTERACTION").addPowerControllerProp(PowerController.OFF).send();
assert.deepEqual([status, error, scene, change], ["", "", "", ""], "each resolves \"\" instead of rejecting");
assert.ok(errors.length >= 4, `every failure reached the error listener: ${errors.join(" | ")}`);
// With no listener the same failure is swallowed: an "error" event without a listener would throw.
const quiet = await connected(new Alex2MQTT("u", "p", "root-send-quiet", false, { host: url }));
const q = quiet.registerDevice("Q", "q-1", null);
await quiet.disconnect();
assert.equal(await q.getStatusMessage("ct", true).addPowerControllerProp(PowerController.ON).send(), "");
});

89
test/directives.test.js Normal file
View file

@ -0,0 +1,89 @@
"use strict";
// Directives: a message on <root>/<endpointId>/alexaDirective reaches the device's "Event" or "ReportState" listener,
// and what the device answers is published on <root>/<endpointId>/alexaResponce.
const { test } = require("node:test");
const assert = require("node:assert/strict");
const {
Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController, EndpointHealth, TemperatureSensorScale,
} = require("../dist/index.js");
const { setup, directive, until } = require("./helpers/harness.js");
// A light that answers every directive with its new power state and ReportState with "off at 0 %".
function diningRoomLight(bridge) {
const lamp = bridge.registerDevice("Dining Room Light", "5020AA", DisplayCategory.LIGHT);
lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER, { proactivelyReported: true });
lamp.addCapability(AlexaInterfaceType.BRIGHTNESS_CONTROLLER);
lamp.on("Event", (request) => {
lamp.getStatusMessage(request.header.correlationToken, true)
.addHealthProp(EndpointHealth.OK)
.addPowerControllerProp(request.header.name === "TurnOn" ? PowerController.ON : PowerController.OFF)
.addBrightnessControllerProp(100)
.send(false);
});
lamp.on("ReportState", (request) => {
lamp.getStatusMessage(request.header.correlationToken)
.addPowerControllerProp(PowerController.OFF)
.addBrightnessControllerProp(0)
.send(false);
});
return lamp;
}
test("a directive reaches the \"Event\" listener and is answered with a Response carrying its correlationToken", async () => {
const { alexa, bridge } = await setup(Alex2MQTT, "root");
const dispatched = [];
bridge.on("directive", (info) => dispatched.push(info));
diningRoomLight(bridge);
alexa.send("root", directive("Alexa.PowerController", "TurnOn", "5020AA", "ct1"));
await until(() => alexa.on("root/5020AA/alexaResponce").length === 1, 3000, "directive response");
const [response] = alexa.on("root/5020AA/alexaResponce");
assert.equal(response.event.header.name, "Response");
assert.equal(response.event.header.correlationToken, "ct1");
assert.equal(response.context.properties.find((p) => p.name === "powerState").value, "ON");
assert.deepEqual(dispatched, [{ endpointId: "5020AA", namespace: "Alexa.PowerController", name: "TurnOn" }]);
});
test("ReportState reaches the \"ReportState\" listener and is answered with a StateReport", async () => {
const { alexa, bridge } = await setup(Alex2MQTT, "root");
diningRoomLight(bridge);
alexa.send("root", directive("Alexa", "ReportState", "5020AA", "ct2"));
await until(() => alexa.on("root/5020AA/alexaResponce").length === 1, 3000, "state report");
const [report] = alexa.on("root/5020AA/alexaResponce");
assert.equal(report.event.header.name, "StateReport");
assert.equal(report.event.header.correlationToken, "ct2");
assert.deepEqual(report.context.properties.map((p) => [p.name, p.value]), [["powerState", "OFF"], ["brightness", 0]]);
});
test("SceneController: Activate is answered with ActivationStarted", async () => {
const { alexa, bridge } = await setup(Alex2MQTT, "root");
const scene = bridge.registerDevice("Evening", "scene-1", DisplayCategory.SCENE_TRIGGER);
scene.addCapability(AlexaInterfaceType.SCENE_CONTROLLER);
scene.on("Event", (request) => {
scene.sendSceneResponse(request.header.correlationToken, request.header.name === "Activate");
});
alexa.send("root", directive("Alexa.SceneController", "Activate", "scene-1", "ct3"));
await until(() => alexa.on("root/scene-1/alexaResponce").length === 1, 3000, "scene response");
const [started] = alexa.on("root/scene-1/alexaResponce");
assert.equal(started.event.header.namespace, "Alexa.SceneController");
assert.equal(started.event.header.name, "ActivationStarted");
assert.equal(started.event.payload.cause.type, "VOICE_INTERACTION");
assert.ok(started.event.payload.timestamp);
});
test("the temperature helpers convert Fahrenheit to Celsius (as 1.4.0 did)", async () => {
const { bridge } = await setup(Alex2MQTT, "root");
const lamp = diningRoomLight(bridge);
const message = lamp.getStatusMessage("x")
.addTemperatureSensorProp(TemperatureSensorScale.FAHRENHEIT, 68)
.addThermostatControllerProp("lowerSetpoint", TemperatureSensorScale.FAHRENHEIT, 68)
.toJSON();
const toOneDecimal = (n) => Math.round(n * 10) / 10;
for (const name of ["temperature", "lowerSetpoint"]) {
const { value } = message.context.properties.find((p) => p.name === name);
assert.equal(toOneDecimal(value.value), 20, name);
assert.equal(value.scale, "CELSIUS", name);
}
});

104
test/discovery.test.js Normal file
View file

@ -0,0 +1,104 @@
"use strict";
// Discovery: a message on <root>/discover is answered on <root>/discover_r with one JSON array, an endpoint object
// per registered device.
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { Alex2MQTT, AlexaInterfaceType, DisplayCategory } = require("../dist/index.js");
const { setup, connected, until } = require("./helpers/harness.js");
// Ask as Alex2MQTT does and return the endpoints of the one answer.
async function discover(alexa, root) {
alexa.publish(`${root}/discover`, { namespace: "Alexa.Discovery", name: "Discover" });
await until(() => alexa.on(`${root}/discover_r`).length === 1, 3000, "discovery reply");
return alexa.on(`${root}/discover_r`)[0];
}
test("discovery answers with every registered device and its capabilities", async () => {
const { alexa, bridge } = await setup(Alex2MQTT, "root");
const discovered = [];
bridge.on("discover", (n) => discovered.push(n));
const lamp = bridge.registerDevice("Dining Room Light", "5020AA", DisplayCategory.LIGHT);
lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER, { proactivelyReported: true });
lamp.addCapability(AlexaInterfaceType.BRIGHTNESS_CONTROLLER);
const scene = bridge.registerDevice("Evening", "scene-1", DisplayCategory.SCENE_TRIGGER);
scene.addCapability(AlexaInterfaceType.SCENE_CONTROLLER);
const endpoints = await discover(alexa, "root");
assert.equal(endpoints.length, 2);
assert.equal(endpoints[0].friendlyName, "Dining Room Light");
assert.deepEqual(endpoints[0].displayCategories, ["LIGHT"]);
const power = endpoints[0].capabilities.find((c) => c.interface === "Alexa.PowerController");
assert.equal(power.properties.proactivelyReported, true);
assert.deepEqual(power.properties.supported, [{ name: "powerState" }]);
// Alexa.SceneController has no properties block
const activation = endpoints[1].capabilities.find((c) => c.interface === "Alexa.SceneController");
assert.equal(activation.supportsDeactivation, true);
assert.equal(activation.version, "3");
assert.equal(activation.properties, undefined);
await until(() => discovered.length === 1, 3000, "the discover event");
assert.deepEqual(discovered, [2]);
assert.notEqual(bridge.lastDiscoveryAt, null);
});
test("unregisterDevice: the device is gone from the next discovery answer", async () => {
const { alexa, bridge } = await setup(Alex2MQTT, "root");
const lamp = bridge.registerDevice("Dining Room Light", "5020AA", DisplayCategory.LIGHT);
lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER);
const scene = bridge.registerDevice("Evening", "scene-1", DisplayCategory.SCENE_TRIGGER);
scene.addCapability(AlexaInterfaceType.SCENE_CONTROLLER);
assert.equal(bridge.unregisterDevice("scene-1"), true);
assert.equal(bridge.unregisterDevice("scene-1"), false);
assert.equal(bridge.getDevices().length, 1);
const endpoints = await discover(alexa, "root");
assert.deepEqual(endpoints.map((e) => e.endpointId), ["5020AA"]);
});
test("registerDevice with an endpointId already registered returns the existing device and warns", async () => {
const logs = [];
const { url, alexa, bridge } = await setup(Alex2MQTT, "root", { log: (m) => logs.push(m) });
const first = bridge.registerDevice("Lamp A", "same", DisplayCategory.LIGHT);
first.addCapability(AlexaInterfaceType.POWER_CONTROLLER);
const again = bridge.registerDevice("Lamp B", "same", DisplayCategory.SWITCH);
assert.equal(again, first);
assert.equal(again.name, "Lamp A");
assert.equal(bridge.getDevices().length, 1);
assert.ok(logs.some((m) => /warning: registerDevice\("same"\)/.test(m)), "warned through the log hook");
const endpoints = await discover(alexa, "root");
assert.deepEqual(endpoints.map((e) => e.endpointId), ["same"]);
// Without a log hook (the examples and the Quick Start set none) the warning falls back to console.warn.
const quiet = await connected(new Alex2MQTT("u", "p", "root-quiet", false, { host: url }));
const warned = [];
const realWarn = console.warn;
console.warn = (...args) => warned.push(args.join(" "));
try {
quiet.registerDevice("Q", "same", null);
assert.equal(quiet.registerDevice("Q again", "same", null).name, "Q");
} finally {
console.warn = realWarn;
}
assert.ok(warned.some((m) => /warning: registerDevice\("same"\)/.test(m)), "console.warn without a log hook");
});
test("ThermostatController discovery: version 3.2, targetSetpoint listed with both setpoints and the mode", async () => {
const { alexa, bridge } = await setup(Alex2MQTT, "root");
const lamp = bridge.registerDevice("Lamp", "lamp-1", DisplayCategory.LIGHT);
lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER);
const thermostat = bridge.registerDevice("Thermostat", "thermo-1", DisplayCategory.THERMOSTAT);
thermostat.addCapability(AlexaInterfaceType.THERMOSTAT_CONTROLLER);
const endpoints = await discover(alexa, "root");
assert.deepEqual(endpoints.map((e) => e.endpointId), ["lamp-1", "thermo-1"]);
const capability = endpoints[1].capabilities.find((c) => c.interface === "Alexa.ThermostatController");
assert.equal(capability.version, "3.2");
assert.deepEqual(
capability.properties.supported.map((p) => p.name),
["targetSetpoint", "lowerSetpoint", "upperSetpoint", "thermostatMode"]
);
});

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 };

37
test/typings.test.js Normal file
View file

@ -0,0 +1,37 @@
"use strict";
// The declarations tracked in dist/: what a TypeScript consumer compiles against.
const { test } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { ActionMapping, AlexaActions, AlexaStatusMessage, EndpointHealth } = require("../dist/index.js");
const DIST = path.join(__dirname, "..", "dist");
const MODULES = [
"index", "Alex2Node", "Device", "AlexaInterface", "AlexaStatusMessage", "AlexaErrorResponse", "ActionMapping",
"DisplayCategory",
];
const declaration = (name) => fs.readFileSync(path.join(DIST, `${name}.d.ts`), "utf8");
test("1.5.2 typings: dist ships declarations, ActionMapping's payload is optional, addHealthProp takes the enum or the string", () => {
for (const name of MODULES) assert.ok(fs.existsSync(path.join(DIST, `${name}.d.ts`)), `dist/${name}.d.ts`);
// The declaration text itself: a revert in src/ would rebuild narrower types, and the runtime checks below would
// not notice.
assert.match(declaration("ActionMapping"), /constructor\(actions: AlexaActions\[\], directiveName: string, directivePayload\?: string\);/);
assert.match(declaration("AlexaStatusMessage"), /addHealthProp\(health: EndpointHealth \| `\$\{EndpointHealth\}`, uncertaintyInMs\?: number\): this;/);
assert.match(declaration("AlexaInterface"), /addSupportedModes\(modes: Array<string \| SupportedMode>\): void;/);
assert.match(declaration("index"), /export type \{ SupportedMode \} from "\.\/AlexaInterface";/);
assert.deepEqual(new ActionMapping([AlexaActions.Close], "TurnOn").toJSON(), {
"@type": "ActionsToDirective",
actions: ["Alexa.Actions.Close"],
directive: { name: "TurnOn" },
});
const health = (h) => {
const message = new AlexaStatusMessage("ct", "root", "e", null).addHealthProp(h).toJSON();
return message.context.properties[0].value;
};
assert.deepEqual(health(EndpointHealth.OK), { value: "OK" });
assert.deepEqual(health("UNREACHABLE"), { value: "UNREACHABLE" });
});