From 58869d44c1ff6eee5efe91fde707c13fc5c6f680 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 28 Sep 2026 13:45:38 +0000 Subject: [PATCH] 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 --- test/alex2node.test.js | 194 ------------------------------------- test/change-report.test.js | 29 ++++++ test/connection.test.js | 83 ++++++++++++++++ test/directives.test.js | 89 +++++++++++++++++ test/discovery.test.js | 104 ++++++++++++++++++++ test/helpers/harness.js | 105 ++++++++++++++++++++ test/typings.test.js | 37 +++++++ 7 files changed, 447 insertions(+), 194 deletions(-) delete mode 100644 test/alex2node.test.js create mode 100644 test/change-report.test.js create mode 100644 test/connection.test.js create mode 100644 test/directives.test.js create mode 100644 test/discovery.test.js create mode 100644 test/helpers/harness.js create mode 100644 test/typings.test.js diff --git a/test/alex2node.test.js b/test/alex2node.test.js deleted file mode 100644 index f05d3e4..0000000 --- a/test/alex2node.test.js +++ /dev/null @@ -1,194 +0,0 @@ -"use strict"; -// Alex2Node against an in-process MQTT broker (aedes): the 1.4.0 contract (discovery on /discover -> /discover_r, -// directives on //alexaDirective -> Response on .../alexaResponce) and the 1.5.1 additions: a configurable -// broker, connection errors as events that never crash the host, ChangeReport on /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 /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 /# -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\): 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(), ""); -}); diff --git a/test/change-report.test.js b/test/change-report.test.js new file mode 100644 index 0000000..e0d4579 --- /dev/null +++ b/test/change-report.test.js @@ -0,0 +1,29 @@ +"use strict"; +// Alexa.ChangeReport: published on /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]]); +}); diff --git a/test/connection.test.js b/test/connection.test.js new file mode 100644 index 0000000..99c2777 --- /dev/null +++ b/test/connection.test.js @@ -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(), ""); +}); diff --git a/test/directives.test.js b/test/directives.test.js new file mode 100644 index 0000000..f644b49 --- /dev/null +++ b/test/directives.test.js @@ -0,0 +1,89 @@ +"use strict"; +// Directives: a message on //alexaDirective reaches the device's "Event" or "ReportState" listener, +// and what the device answers is published on //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); + } +}); diff --git a/test/discovery.test.js b/test/discovery.test.js new file mode 100644 index 0000000..2e96212 --- /dev/null +++ b/test/discovery.test.js @@ -0,0 +1,104 @@ +"use strict"; +// Discovery: a message on /discover is answered on /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"] + ); +}); diff --git a/test/helpers/harness.js b/test/helpers/harness.js new file mode 100644 index 0000000..44cb568 --- /dev/null +++ b/test/helpers/harness.js @@ -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 " 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: //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 /#, 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 }; diff --git a/test/typings.test.js b/test/typings.test.js new file mode 100644 index 0000000..fe6c2f3 --- /dev/null +++ b/test/typings.test.js @@ -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\): 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" }); +});