dispatch: typed handlers, respond/defer/error, automatic ErrorResponse, watchdog
src/dispatcher.ts routes a directive to capability.on(name | "*"), device.onDirective() or device.onReportState(), with the payload checked by the descriptor of the interface. The DirectiveContext answers with respond/report/defer/error; respond() and report() start from device.state(). A handler or 1.x listener that throws or rejects is answered with INTERNAL_ERROR (an AlexaError with itself) and reported through "error", never as an unhandled rejection. Undeclared interface, unknown directive, AdjustMode on an unordered mode and a missing handler get INVALID_DIRECTIVE, a bad payload INVALID_VALUE; a device with an "Event" or "ReportState" listener keeps the answer to itself. Every answer passes the dispatcher: the first one per correlationToken is published, a second is refused. No answer within answerWithinMs (6500, 0 = off, unref'd timer) sends INTERNAL_ERROR and emits "unanswered". A messageId that arrived in the last 60 s is dropped. New: "unknownEndpoint", options answerUnknownEndpoints, publisher, timers, and bridge.receive() to run a bridge without a broker. 168 tests pass (18 new). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
e0fe9d2812
commit
feff828ec1
40 changed files with 2149 additions and 136 deletions
442
test/dispatch/dispatch.test.js
Normal file
442
test/dispatch/dispatch.test.js
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
"use strict";
|
||||
// Dispatch without a broker: a bridge that publishes to a MemoryPublisher and is given its messages by receive().
|
||||
// The watchdog runs on timers the test fires by hand.
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const {
|
||||
Alex2MQTT, AlexaErrors, AlexaInterfaceType, DeclarationError, MemoryPublisher, ModeController, PowerController,
|
||||
PowerState, RangeController, BrightnessController, asset, text,
|
||||
} = require("alex2node");
|
||||
const { directive } = require("../helpers/harness.js");
|
||||
|
||||
// Timers that wait for fire()
|
||||
function manualTimers() {
|
||||
const pending = [];
|
||||
return {
|
||||
pending,
|
||||
setTimeout(run, ms) {
|
||||
const timer = { run, ms };
|
||||
pending.push(timer);
|
||||
return timer;
|
||||
},
|
||||
clearTimeout(timer) {
|
||||
const i = pending.indexOf(timer);
|
||||
if (i !== -1) pending.splice(i, 1);
|
||||
},
|
||||
fire() {
|
||||
for (const timer of pending.splice(0)) timer.run();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function bridgeWithout(options = {}) {
|
||||
const sent = new MemoryPublisher();
|
||||
const timers = manualTimers();
|
||||
const logged = [];
|
||||
const bridge = new Alex2MQTT("u", "p", "root", false, { publisher: sent, timers, log: (line) => logged.push(line), ...options });
|
||||
const errors = [];
|
||||
bridge.on("error", (err) => errors.push(err));
|
||||
/** What was published, as [the last part of the topic, the name of the event, its payload]. */
|
||||
const answers = () => sent.published.map(({ topic, message }) => [topic.split("/").pop(), message.event.header.name, message.event.payload]);
|
||||
const receive = (message) => bridge.receive(`root/${message.endpoint.endpointId}/alexaDirective`, JSON.stringify(message));
|
||||
return { bridge, sent, timers, logged, errors, answers, receive };
|
||||
}
|
||||
|
||||
const instanced = (message, instance) => ({ ...message, header: { ...message.header, instance } });
|
||||
const nextTurn = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Blinds with a lift from 0 to 100 and a tilt in three unordered modes
|
||||
function blinds(bridge) {
|
||||
const device = bridge.addDevice({ endpointId: "blinds-1", name: "Blinds", categories: ["INTERIOR_BLIND"] });
|
||||
const lift = device.add(RangeController, {
|
||||
instance: "Blind.Lift", friendlyNames: [asset("Alexa.Setting.Opening")], range: { min: 0, max: 100, precision: 1 },
|
||||
});
|
||||
const mode = (value) => ({ value, friendlyNames: [text(value, "en-US")] });
|
||||
const tilt = device.add(ModeController, {
|
||||
instance: "Blind.Tilt", friendlyNames: [text("tilt", "en-US")], supportedModes: [mode("Up"), mode("Flat"), mode("Down")],
|
||||
});
|
||||
return { device, lift, tilt };
|
||||
}
|
||||
|
||||
test("a directive reaches the handler of its capability with a checked payload, and respond() answers with the state of the device under what the handler set", async () => {
|
||||
const { bridge, sent, timers, receive } = bridgeWithout();
|
||||
const { device, lift, tilt } = blinds(bridge);
|
||||
device.state((s) => s.set(lift, "rangeValue", 10).set(tilt, "mode", "Flat").health("OK"));
|
||||
const seen = [];
|
||||
lift.on("SetRangeValue", (ctx) => {
|
||||
seen.push([ctx.endpointId, ctx.namespace, ctx.name, ctx.instance, ctx.correlationToken, ctx.payload, ctx.answered]);
|
||||
return ctx.respond((s) => s.set(lift, "rangeValue", ctx.payload.rangeValue));
|
||||
});
|
||||
|
||||
await receive(instanced(directive("Alexa.RangeController", "SetRangeValue", "blinds-1", "ct-1", { rangeValue: 7 }), "Blind.Lift"));
|
||||
assert.deepEqual(seen, [["blinds-1", "Alexa.RangeController", "SetRangeValue", "Blind.Lift", "ct-1", { rangeValue: 7 }, false]]);
|
||||
assert.equal(sent.published.length, 1);
|
||||
const [{ topic, message }] = sent.published;
|
||||
assert.equal(topic, "root/blinds-1/alexaResponce");
|
||||
assert.deepEqual([message.event.header.namespace, message.event.header.name, message.event.header.correlationToken], ["Alexa", "Response", "ct-1"]);
|
||||
assert.deepEqual(message.context.properties.map((p) => [p.name, p.instance, p.value]), [
|
||||
["mode", "Blind.Tilt", "Flat"],
|
||||
["connectivity", undefined, { value: "OK" }],
|
||||
["rangeValue", "Blind.Lift", 7],
|
||||
]);
|
||||
assert.deepEqual(timers.pending, [], "an answered directive leaves no timer");
|
||||
});
|
||||
|
||||
test("a directive no handler can be called for is answered with INVALID_DIRECTIVE", async () => {
|
||||
const { bridge, answers, receive, errors } = bridgeWithout();
|
||||
const { lift, tilt } = blinds(bridge);
|
||||
lift.on("SetRangeValue", (ctx) => ctx.respond());
|
||||
tilt.on("*", (ctx) => ctx.respond());
|
||||
const range = (name, payload) => directive("Alexa.RangeController", name, "blinds-1", `ct-${name}`, payload);
|
||||
|
||||
await receive(directive("Alexa.PowerController", "TurnOn", "blinds-1", "ct-undeclared"));
|
||||
await receive(instanced(range("SetRangeValue", { rangeValue: 7 }), "Blind.Height"));
|
||||
await receive(instanced(range("SetRange", { rangeValue: 7 }), "Blind.Lift"));
|
||||
await receive(instanced(range("AdjustRangeValue", { rangeValueDelta: 5, rangeValueDeltaDefault: false }), "Blind.Lift"));
|
||||
await receive(instanced(directive("Alexa.ModeController", "AdjustMode", "blinds-1", "ct-unordered", { modeDelta: 1 }), "Blind.Tilt"));
|
||||
assert.deepEqual(answers(), [
|
||||
["alexaResponce", "ErrorResponse", { type: "INVALID_DIRECTIVE", message: "Alexa.PowerController is not declared on blinds-1" }],
|
||||
["alexaResponce", "ErrorResponse", { type: "INVALID_DIRECTIVE", message: "Alexa.RangeController \"Blind.Height\" is not declared on blinds-1" }],
|
||||
["alexaResponce", "ErrorResponse", { type: "INVALID_DIRECTIVE", message: "SetRange is not a directive of Alexa.RangeController" }],
|
||||
["alexaResponce", "ErrorResponse", {
|
||||
type: "INVALID_DIRECTIVE",
|
||||
message: "blinds-1 has no handler for Alexa.RangeController.AdjustRangeValue: register one with on(\"AdjustRangeValue\", handler) of the capability",
|
||||
}],
|
||||
["alexaResponce", "ErrorResponse", { type: "INVALID_DIRECTIVE", message: "Alexa.ModeController \"Blind.Tilt\" as blinds-1 declares it does not take AdjustMode" }],
|
||||
]);
|
||||
assert.deepEqual(errors, [], "the sender was wrong, not the bridge");
|
||||
});
|
||||
|
||||
test("a payload the interface does not describe is answered with INVALID_VALUE and the handler is not called", async () => {
|
||||
const { bridge, answers, receive } = bridgeWithout();
|
||||
const lamp = bridge.addDevice({ endpointId: "lamp-1", name: "Lamp", categories: ["LIGHT"] });
|
||||
let called = 0;
|
||||
lamp.add(BrightnessController).on("SetBrightness", () => { called += 1; });
|
||||
|
||||
await receive(directive("Alexa.BrightnessController", "SetBrightness", "lamp-1", "ct-1", { brightness: "lots" }));
|
||||
assert.equal(called, 0);
|
||||
const [[, name, payload]] = answers();
|
||||
assert.equal(name, "ErrorResponse");
|
||||
assert.equal(payload.type, "INVALID_VALUE");
|
||||
assert.match(payload.message, /^payload\.brightness: expected .*got "lots"$/);
|
||||
});
|
||||
|
||||
test("on(): \"*\" and onDirective() take what has no handler of its own; a name that is no directive is refused", async () => {
|
||||
const { bridge, receive } = bridgeWithout();
|
||||
const lamp = bridge.addDevice({ endpointId: "lamp-1", name: "Lamp", categories: ["LIGHT"] });
|
||||
const power = lamp.add(PowerController);
|
||||
lamp.add(BrightnessController);
|
||||
const called = [];
|
||||
const by = (who) => (ctx) => { called.push(`${who} ${ctx.name}`); return ctx.respond(); };
|
||||
power.on("TurnOn", by("TurnOn")).on("*", by("*"));
|
||||
lamp.onDirective(by("device"));
|
||||
|
||||
await receive(directive("Alexa.PowerController", "TurnOn", "lamp-1", "ct-1"));
|
||||
await receive(directive("Alexa.PowerController", "TurnOff", "lamp-1", "ct-2"));
|
||||
await receive(directive("Alexa.BrightnessController", "SetBrightness", "lamp-1", "ct-3", { brightness: 40 }));
|
||||
assert.deepEqual(called, ["TurnOn TurnOn", "* TurnOff", "device SetBrightness"]);
|
||||
assert.throws(() => power.on("Toggle", by("Toggle")), (err) => err instanceof DeclarationError
|
||||
&& err.message === "lamp-1: Alexa.PowerController: Toggle is not a directive of the interface, which has TurnOn, TurnOff");
|
||||
});
|
||||
|
||||
test("a handler that throws or rejects answers INTERNAL_ERROR, the bridge reports what it threw, and nothing is left unhandled", async () => {
|
||||
const unhandled = [];
|
||||
const note = (reason) => unhandled.push(reason);
|
||||
process.on("unhandledRejection", note);
|
||||
const { bridge, answers, receive, errors } = bridgeWithout();
|
||||
const lamp = bridge.addDevice({ endpointId: "lamp-1", name: "Lamp", categories: ["LIGHT"] });
|
||||
const power = lamp.add(PowerController);
|
||||
power.on("TurnOn", () => { throw new TypeError("relay is undefined"); });
|
||||
power.on("TurnOff", async () => { await nextTurn(); throw new Error("the relay did not answer"); });
|
||||
|
||||
await receive(directive("Alexa.PowerController", "TurnOn", "lamp-1", "ct-1"));
|
||||
await receive(directive("Alexa.PowerController", "TurnOff", "lamp-1", "ct-2"));
|
||||
await nextTurn();
|
||||
process.off("unhandledRejection", note);
|
||||
assert.deepEqual(answers(), [
|
||||
["alexaResponce", "ErrorResponse", { type: "INTERNAL_ERROR", message: "relay is undefined" }],
|
||||
["alexaResponce", "ErrorResponse", { type: "INTERNAL_ERROR", message: "the relay did not answer" }],
|
||||
]);
|
||||
assert.ok(errors[0] instanceof TypeError, "the error as it was thrown");
|
||||
assert.deepEqual(errors.map((err) => err.message), ["relay is undefined", "the relay did not answer"]);
|
||||
assert.deepEqual(unhandled, []);
|
||||
});
|
||||
|
||||
test("an AlexaError a handler throws is the answer, under the namespace of its type; ctx.error() sends the same", async () => {
|
||||
const { bridge, sent, receive, errors } = bridgeWithout();
|
||||
const { lift } = blinds(bridge);
|
||||
const outOfRange = AlexaErrors.valueOutOfRange("the lift goes from 0 to 100", { minimumValue: 0, maximumValue: 100 });
|
||||
lift.on("SetRangeValue", (ctx) => {
|
||||
if (ctx.payload.rangeValue > 100) throw outOfRange;
|
||||
if (ctx.payload.rangeValue === 50) return ctx.error(AlexaErrors.of("OBSTACLE_DETECTED", "something is in the way"));
|
||||
return ctx.error("ENDPOINT_UNREACHABLE", "the motor is unplugged", { since: "today" });
|
||||
});
|
||||
const set = (rangeValue) => receive(instanced(directive("Alexa.RangeController", "SetRangeValue", "blinds-1", `ct-${rangeValue}`, { rangeValue }), "Blind.Lift"));
|
||||
|
||||
await set(101);
|
||||
await set(50);
|
||||
await set(1);
|
||||
assert.deepEqual(sent.published.map(({ message }) => [message.event.header.namespace, message.event.header.correlationToken, message.event.payload]), [
|
||||
["Alexa", "ct-101", { type: "VALUE_OUT_OF_RANGE", message: "the lift goes from 0 to 100", validRange: { minimumValue: 0, maximumValue: 100 } }],
|
||||
["Alexa.Safety", "ct-50", { type: "OBSTACLE_DETECTED", message: "something is in the way" }],
|
||||
["Alexa", "ct-1", { type: "ENDPOINT_UNREACHABLE", message: "the motor is unplugged", since: "today" }],
|
||||
]);
|
||||
assert.deepEqual(errors, [], "an error the handler chose is not a fault of the bridge");
|
||||
});
|
||||
|
||||
test("defer(): a DeferredResponse without a context now, the answer on deferredResponse, a second defer() refused", async () => {
|
||||
const { bridge, sent, answers, receive, timers, logged, errors } = bridgeWithout();
|
||||
const lamp = bridge.addDevice({ endpointId: "lamp-1", name: "Lamp", categories: ["LIGHT"], endpointHealth: false });
|
||||
const results = [];
|
||||
lamp.add(PowerController).on("TurnOn", async (ctx) => {
|
||||
results.push(await ctx.defer(5), [ctx.deferred, ctx.answered]);
|
||||
assert.deepEqual(timers.pending, [], "the watchdog waits for the first answer only");
|
||||
results.push(await ctx.defer());
|
||||
results.push(await ctx.respond((s) => s.set(PowerController, "powerState", "ON")), [ctx.deferred, ctx.answered]);
|
||||
});
|
||||
|
||||
await receive(directive("Alexa.PowerController", "TurnOn", "lamp-1", "ct-1"));
|
||||
await receive(directive("Alexa.PowerController", "TurnOn", "lamp-1", "ct-2"));
|
||||
assert.deepEqual(answers().slice(0, 2), [
|
||||
["alexaResponce", "DeferredResponse", { estimatedDeferralInSeconds: 5 }],
|
||||
["deferredResponse", "Response", {}],
|
||||
]);
|
||||
assert.equal("context" in sent.published[0].message, false);
|
||||
assert.equal(sent.published.length, 4);
|
||||
assert.deepEqual(results.slice(0, 5).map((result) => (Array.isArray(result) ? result : result.ok)), [true, [true, false], false, true, [true, true]]);
|
||||
assert.equal(
|
||||
results[2].error.message,
|
||||
"nothing was published to root/lamp-1/alexaResponce: Alexa.PowerController.TurnOn for lamp-1 was deferred before, and Alex2MQTT takes one answer"
|
||||
);
|
||||
assert.deepEqual(errors.map((err) => err.message), [results[2].error.message, results[7].error.message]);
|
||||
const warnings = logged.filter((line) => line.startsWith("warning: Amazon documents no DeferredResponse"));
|
||||
assert.deepEqual(warnings, ["warning: Amazon documents no DeferredResponse for Alexa.PowerController, Alexa may not wait for the answer"]);
|
||||
});
|
||||
|
||||
test("the watchdog: a directive nobody answers gets INTERNAL_ERROR and the bridge emits \"unanswered\"; a late answer is refused", async () => {
|
||||
const { bridge, answers, receive, timers, errors } = bridgeWithout();
|
||||
const lamp = bridge.addDevice({ endpointId: "lamp-1", name: "Lamp", categories: ["LIGHT"] });
|
||||
let late;
|
||||
lamp.add(PowerController).on("TurnOn", (ctx) => { late = ctx; });
|
||||
const unanswered = [];
|
||||
bridge.on("unanswered", (info) => unanswered.push(info));
|
||||
|
||||
await receive(directive("Alexa.PowerController", "TurnOn", "lamp-1", "ct-1"));
|
||||
assert.deepEqual(answers(), []);
|
||||
assert.deepEqual(timers.pending.map((timer) => timer.ms), [6500], "before Alex2MQTT gives up at 7 s");
|
||||
timers.fire();
|
||||
await nextTurn();
|
||||
assert.deepEqual(answers(), [["alexaResponce", "ErrorResponse", { type: "INTERNAL_ERROR", message: "no answer within 6.5 s" }]]);
|
||||
assert.deepEqual(unanswered, [{ endpointId: "lamp-1", namespace: "Alexa.PowerController", name: "TurnOn", correlationToken: "ct-1" }]);
|
||||
assert.match(errors[0].message, /^lamp-1: Alexa\.PowerController\.TurnOn got no answer within 6\.5 s/);
|
||||
|
||||
const result = await late.respond();
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error.message, /was answered before/);
|
||||
assert.equal(answers().length, 1);
|
||||
});
|
||||
|
||||
test("answerWithinMs: the time of the watchdog, 0 for none, anything else refused", async () => {
|
||||
const quick = bridgeWithout({ answerWithinMs: 200 });
|
||||
const never = bridgeWithout({ answerWithinMs: 0 });
|
||||
for (const { bridge, receive } of [quick, never]) {
|
||||
bridge.addDevice({ endpointId: "lamp-1", name: "Lamp", categories: ["LIGHT"] }).add(PowerController).on("TurnOn", () => {});
|
||||
await receive(directive("Alexa.PowerController", "TurnOn", "lamp-1", "ct-1"));
|
||||
}
|
||||
assert.deepEqual(quick.timers.pending.map((timer) => timer.ms), [200]);
|
||||
assert.deepEqual(never.timers.pending, []);
|
||||
assert.throws(
|
||||
() => bridgeWithout({ answerWithinMs: -1 }),
|
||||
{ message: "answerWithinMs is -1: pass the milliseconds a handler has to answer in, or 0 for no limit" }
|
||||
);
|
||||
});
|
||||
|
||||
test("the timer of the watchdog does not keep the process alive", () => {
|
||||
const script = `
|
||||
const { Alex2MQTT, MemoryPublisher, PowerController } = require(${JSON.stringify(path.join(__dirname, "..", ".."))});
|
||||
const bridge = new Alex2MQTT("u", "p", "root", false, { publisher: new MemoryPublisher(), answerWithinMs: 60000 });
|
||||
bridge.addDevice({ endpointId: "lamp-1", name: "Lamp", categories: ["LIGHT"] }).add(PowerController).on("TurnOn", () => {});
|
||||
const header = { namespace: "Alexa.PowerController", name: "TurnOn", messageId: "m-1", correlationToken: "ct-1", payloadVersion: "3" };
|
||||
bridge.receive("root/lamp-1/alexaDirective", JSON.stringify({ header, endpoint: { endpointId: "lamp-1" }, payload: {} }));
|
||||
`;
|
||||
const run = spawnSync(process.execPath, ["-e", script], { encoding: "utf8", timeout: 10000 });
|
||||
assert.equal(run.status, 0, `the process did not end by itself: ${run.error || run.stderr}`);
|
||||
});
|
||||
|
||||
test("a directive that arrives twice is handled once, by its messageId", async () => {
|
||||
const { bridge, answers, receive, logged } = bridgeWithout();
|
||||
const called = [];
|
||||
for (const endpointId of ["lamp-1", "lamp-2"]) {
|
||||
const lamp = bridge.addDevice({ endpointId, name: `Lamp ${endpointId.slice(-1)}`, categories: ["LIGHT"] });
|
||||
lamp.add(PowerController).on("TurnOn", (ctx) => { called.push(endpointId); return ctx.respond(); });
|
||||
}
|
||||
const turnOn = directive("Alexa.PowerController", "TurnOn", "lamp-1", "ct-1");
|
||||
|
||||
await receive(turnOn);
|
||||
await receive(turnOn);
|
||||
// The messageId is new to the other endpoint
|
||||
await receive({ ...turnOn, endpoint: { endpointId: "lamp-2" } });
|
||||
assert.deepEqual(called, ["lamp-1", "lamp-2"]);
|
||||
assert.equal(answers().length, 2);
|
||||
assert.ok(logged.includes("the directive message-ct-1 for lamp-1 is dropped: it arrived before"));
|
||||
});
|
||||
|
||||
test("a directive for an endpoint the bridge does not have: \"unknownEndpoint\", and NO_SUCH_ENDPOINT only when asked for", async () => {
|
||||
const silent = bridgeWithout();
|
||||
const answering = bridgeWithout({ answerUnknownEndpoints: true });
|
||||
const unknown = [];
|
||||
silent.bridge.on("unknownEndpoint", (info) => unknown.push(info));
|
||||
|
||||
await silent.receive(directive("Alexa.PowerController", "TurnOn", "lamp-9", "ct-1"));
|
||||
await answering.receive(directive("Alexa.PowerController", "TurnOn", "lamp-9", "ct-1"));
|
||||
assert.deepEqual(unknown, [{ endpointId: "lamp-9", namespace: "Alexa.PowerController", name: "TurnOn" }]);
|
||||
assert.deepEqual(silent.answers(), []);
|
||||
assert.deepEqual(silent.timers.pending, []);
|
||||
assert.deepEqual(answering.answers(), [
|
||||
["alexaResponce", "ErrorResponse", { type: "NO_SUCH_ENDPOINT", message: "lamp-9 is not an endpoint of this bridge" }],
|
||||
]);
|
||||
});
|
||||
|
||||
test("what is not a directive is dropped: nothing published, nothing thrown", async () => {
|
||||
const { bridge, answers, errors, logged, timers } = bridgeWithout();
|
||||
bridge.addDevice({ endpointId: "lamp-1", name: "Lamp", categories: ["LIGHT"] });
|
||||
await bridge.receive("root/lamp-1/alexaDirective", "{ not JSON");
|
||||
await bridge.receive("root/lamp-1/alexaDirective", "null");
|
||||
await bridge.receive("root/lamp-1/alexaDirective", JSON.stringify({ payload: {} }));
|
||||
assert.deepEqual([answers(), errors, timers.pending], [[], [], []]);
|
||||
assert.deepEqual(logged.filter((line) => line.includes("dropped")), [
|
||||
"the directive for lamp-1 is dropped: it is not JSON",
|
||||
"the directive for lamp-1 is dropped: it has no header",
|
||||
"the directive for lamp-1 is dropped: it has no header",
|
||||
]);
|
||||
});
|
||||
|
||||
test("ReportState: answered from state() of the device, by onReportState(), or with INVALID_DIRECTIVE when there is neither", async () => {
|
||||
const { bridge, sent, answers, receive } = bridgeWithout();
|
||||
const lamp = bridge.addDevice({ endpointId: "lamp-1", name: "Lamp", categories: ["LIGHT"] });
|
||||
lamp.add(PowerController);
|
||||
const stateOf = () => sent.published[sent.published.length - 1].message.context.properties.map((p) => [p.name, p.value]);
|
||||
|
||||
await receive(directive("Alexa", "ReportState", "lamp-1", "ct-1"));
|
||||
assert.deepEqual(answers()[0].slice(0, 2), ["alexaResponce", "ErrorResponse"]);
|
||||
assert.equal(answers()[0][2].type, "INVALID_DIRECTIVE");
|
||||
|
||||
lamp.state((s) => s.set(PowerController, "powerState", "OFF").health("OK"));
|
||||
await receive(directive("Alexa", "ReportState", "lamp-1", "ct-2"));
|
||||
assert.deepEqual(answers()[1], ["alexaResponce", "StateReport", {}]);
|
||||
assert.deepEqual(stateOf(), [["powerState", "OFF"], ["connectivity", { value: "OK" }]]);
|
||||
|
||||
lamp.onReportState((ctx) => ctx.report((s) => s.health("UNREACHABLE")));
|
||||
await receive(directive("Alexa", "ReportState", "lamp-1", "ct-3"));
|
||||
assert.deepEqual(stateOf(), [["powerState", "OFF"], ["connectivity", { value: "UNREACHABLE" }]]);
|
||||
|
||||
lamp.capability("Alexa.PowerController").on("TurnOn", (ctx) => ctx.report());
|
||||
await receive(directive("Alexa.PowerController", "TurnOn", "lamp-1", "ct-4"));
|
||||
assert.deepEqual(answers()[3][2], {
|
||||
type: "INTERNAL_ERROR",
|
||||
message: "report() answers ReportState with a StateReport: answer Alexa.PowerController.TurnOn with respond()",
|
||||
});
|
||||
});
|
||||
|
||||
// The device of a 1.x caller: declared by name, one listener for every directive, the answer an AlexaStatusMessage
|
||||
function legacyLamp(bridge, onEvent) {
|
||||
const lamp = bridge.registerDevice("Lamp", "lamp-1", ["LIGHT"]);
|
||||
lamp.addCapability("Alexa.PowerController", { proactivelyReported: true });
|
||||
lamp.addCapability(AlexaInterfaceType.ENDPOINT_HEALTH);
|
||||
lamp.on("Event", onEvent);
|
||||
lamp.on("ReportState", (request) => lamp.getStatusMessage(request.header.correlationToken).addPowerControllerProp(PowerState.OFF).send());
|
||||
return lamp;
|
||||
}
|
||||
|
||||
test("1.x: \"Event\" and \"ReportState\" listeners get every directive and their AlexaStatusMessage is the answer", async () => {
|
||||
const { bridge, answers, receive, timers, errors } = bridgeWithout();
|
||||
const got = [];
|
||||
const lamp = legacyLamp(bridge, (request, namespace) => {
|
||||
got.push([namespace, request.header.name, request.payload]);
|
||||
if (namespace !== "Alexa.PowerController") {
|
||||
const error = lamp.getErrorMessage(request.header.correlationToken);
|
||||
error.setErrorMessage("INVALID_DIRECTIVE", "a lamp has no thermostat");
|
||||
return error.send();
|
||||
}
|
||||
return lamp.getStatusMessage(request.header.correlationToken, true).addPowerControllerProp(PowerState.ON).send();
|
||||
});
|
||||
|
||||
await receive(directive("Alexa.PowerController", "TurnOn", "lamp-1", "ct-1"));
|
||||
await receive(directive("Alexa.ThermostatController", "SetTargetTemperature", "lamp-1", "ct-2", { targetSetpoint: { value: 20, scale: "CELSIUS" } }));
|
||||
await receive(directive("Alexa", "ReportState", "lamp-1", "ct-3"));
|
||||
assert.deepEqual(got, [
|
||||
["Alexa.PowerController", "TurnOn", {}],
|
||||
["Alexa.ThermostatController", "SetTargetTemperature", { targetSetpoint: { value: 20, scale: "CELSIUS" } }],
|
||||
]);
|
||||
assert.deepEqual(answers(), [
|
||||
["alexaResponce", "Response", {}],
|
||||
["alexaResponce", "ErrorResponse", { type: "INVALID_DIRECTIVE", message: "a lamp has no thermostat" }],
|
||||
["alexaResponce", "StateReport", {}],
|
||||
]);
|
||||
assert.deepEqual([timers.pending, errors], [[], []], "each answer stopped its watchdog");
|
||||
});
|
||||
|
||||
test("1.x: a deferred answer, DeferredResponse first and the Response on deferredResponse later", async () => {
|
||||
const { bridge, answers, receive, timers, errors } = bridgeWithout();
|
||||
let finish;
|
||||
const lamp = legacyLamp(bridge, (request) => {
|
||||
const token = request.header.correlationToken;
|
||||
lamp.getStatusMessage(token, false, true).addEstimatedDeferralTime(20).send(false).catch(() => {});
|
||||
finish = () => lamp.getStatusMessage(token, true).addPowerControllerProp(PowerState.ON).send(true);
|
||||
});
|
||||
|
||||
await receive(directive("Alexa.PowerController", "TurnOn", "lamp-1", "ct-1"));
|
||||
assert.deepEqual(timers.pending, []);
|
||||
assert.equal(await finish(), "root/lamp-1/deferredResponse");
|
||||
assert.equal(await finish(), "", "the second answer is not published");
|
||||
assert.deepEqual(answers(), [
|
||||
["alexaResponce", "DeferredResponse", { estimatedDeferralInSeconds: 20 }],
|
||||
["deferredResponse", "Response", {}],
|
||||
]);
|
||||
assert.match(errors[0].message, /Alexa\.PowerController\.TurnOn for lamp-1 was answered before/);
|
||||
});
|
||||
|
||||
test("1.x: a listener that stays silent, throws or rejects gets no INVALID_DIRECTIVE but the answer of the watchdog or INTERNAL_ERROR", async () => {
|
||||
const unhandled = [];
|
||||
const note = (reason) => unhandled.push(reason);
|
||||
process.on("unhandledRejection", note);
|
||||
const { bridge, answers, receive, timers, errors } = bridgeWithout();
|
||||
legacyLamp(bridge, async (request) => {
|
||||
if (request.header.name === "TurnOff") throw new Error("the relay did not answer");
|
||||
});
|
||||
|
||||
await receive(directive("Alexa.SceneController", "Activate", "lamp-1", "ct-1"));
|
||||
assert.deepEqual(answers(), [], "the listener owns the answer");
|
||||
timers.fire();
|
||||
await receive(directive("Alexa.PowerController", "TurnOff", "lamp-1", "ct-2"));
|
||||
await nextTurn();
|
||||
process.off("unhandledRejection", note);
|
||||
assert.deepEqual(answers(), [
|
||||
["alexaResponce", "ErrorResponse", { type: "INTERNAL_ERROR", message: "no answer within 6.5 s" }],
|
||||
["alexaResponce", "ErrorResponse", { type: "INTERNAL_ERROR", message: "the relay did not answer" }],
|
||||
]);
|
||||
assert.equal(errors.length, 2);
|
||||
assert.deepEqual(unhandled, []);
|
||||
});
|
||||
|
||||
test("1.x and a handler on one device: the listener is called first, and the first answer is the one published", async () => {
|
||||
const { bridge, answers, receive, errors } = bridgeWithout();
|
||||
const order = [];
|
||||
const lamp = legacyLamp(bridge, (request) => {
|
||||
order.push("listener");
|
||||
return lamp.getStatusMessage(request.header.correlationToken, true).addPowerControllerProp(PowerState.ON).send();
|
||||
});
|
||||
let second;
|
||||
lamp.capability("Alexa.PowerController").on("TurnOn", async (ctx) => {
|
||||
order.push("handler");
|
||||
second = await ctx.respond();
|
||||
});
|
||||
|
||||
await receive(directive("Alexa.PowerController", "TurnOn", "lamp-1", "ct-1"));
|
||||
assert.deepEqual(order, ["listener", "handler"]);
|
||||
assert.equal(answers().length, 1);
|
||||
assert.equal(second.ok, false);
|
||||
assert.deepEqual(errors.map((err) => err.message), [second.error.message]);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue