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:
parent
275f00f8a7
commit
58869d44c1
7 changed files with 447 additions and 194 deletions
|
|
@ -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(), "");
|
|
||||||
});
|
|
||||||
29
test/change-report.test.js
Normal file
29
test/change-report.test.js
Normal 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
83
test/connection.test.js
Normal 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
89
test/directives.test.js
Normal 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
104
test/discovery.test.js
Normal 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
105
test/helpers/harness.js
Normal 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
37
test/typings.test.js
Normal 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" });
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue