src/transport.ts has the Publisher interface, MqttPublisher (the client the bridge has at the time) and MemoryPublisher (tests without a broker); src/topics.ts names the four topics the library publishes to. Device, AlexaStatusMessage, AlexaErrorResponse and sendSceneResponse shared three copies of the publish-and-report code: they now call one send() that resolves the topic or "" and never rejects. registerDevice() and addDevice() work before connect(); a send() without a connection resolves "" and the "error" event says to call connect(). unregisterDevice() and clearDevices() take the publisher from the device. Device.setMqttClient() is gone, the first constructor argument of Device is ignored, and the message classes take a Publisher where they took the client. Tests: 108 -> 113. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
102 lines
5.7 KiB
JavaScript
102 lines
5.7 KiB
JavaScript
"use strict";
|
|
// The broker connection: the default host, an outage reported as events that never crash the host, devices declared
|
|
// before connect(), 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("alex2node");
|
|
const { broker, watcher, 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("devices declared before connect() (1.x threw): a send() is refused until the bridge connects, then they answer", async () => {
|
|
const { url } = await broker();
|
|
const alexa = await watcher(url, "root-early/#");
|
|
const bridge = new Alex2MQTT("u", "p", "root-early", false, { host: url });
|
|
const errors = [];
|
|
bridge.on("error", (e) => errors.push(e.message));
|
|
const lamp = bridge.registerDevice("Lamp", "lamp-1", DisplayCategory.LIGHT);
|
|
const plug = bridge.addDevice({ endpointId: "plug-1", name: "Plug", categories: ["SMARTPLUG"] });
|
|
for (const device of [lamp, plug]) {
|
|
device.on("Event", (d) => device.getStatusMessage(d.header.correlationToken, true).addPowerControllerProp(PowerController.ON).send());
|
|
}
|
|
|
|
assert.equal(await lamp.getStatusMessage("early", true).addPowerControllerProp(PowerController.ON).send(), "");
|
|
assert.deepEqual(errors, ["nothing was published to root-early/lamp-1/alexaResponce: the bridge is not connected, call connect() first"]);
|
|
|
|
await connected(bridge);
|
|
for (const id of ["lamp-1", "plug-1"]) {
|
|
alexa.send("root-early", directive("Alexa.PowerController", "TurnOn", id, `ct-${id}`));
|
|
await until(() => alexa.on(`root-early/${id}/alexaResponce`).length === 1, 3000, `the response of ${id}`);
|
|
assert.equal(alexa.on(`root-early/${id}/alexaResponce`)[0].event.header.correlationToken, `ct-${id}`);
|
|
}
|
|
});
|
|
|
|
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 bridge has no client until the next connect(): every publish is refused
|
|
|
|
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(), "");
|
|
});
|