Alex2Node/test
David dd071548bc 2.0.0
The interface registry, generated and checked discovery, typed dispatch with
automatic error answers, message builders, proactive events and typed
helpers for every error type; the 1.x API is kept and the examples of 1.5.2
run unchanged. docs/wire-changes.md lists what a 1.x caller can observe.

On 2026-09-28 a real Alexa account drove 33 test devices on this API through
the public Alex2MQTT service: Alexa accepted the discovery of all of them,
50 of 58 cases passed, 4 had no action in the control call and 4 failed on
what the check could see, none on an answer of the library. The readme table
names the interfaces that run covered. Voice commands were not tested yet.

307 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 22:55:23 +00:00
..
compat device: the client of new Device() and setMqttClient() warn once 2026-09-28 21:19:10 +00:00
device device: changeReport(cause, fill) sends a ChangeReport on the 2.0 API 2026-09-28 21:37:37 +00:00
dispatch capability: on() without a function says what it got and what to pass 2026-09-28 21:19:31 +00:00
fixtures examples: one runnable file per recipe on the 2.0 API, the 1.x files in examples/legacy 2026-09-28 21:38:07 +00:00
helpers examples: one runnable file per recipe on the 2.0 API, the 1.x files in examples/legacy 2026-09-28 21:38:07 +00:00
messages errors: typed helpers for every documented error type 2026-09-28 21:14:55 +00:00
registry messages: DoorbellPress is sent with the empty context of its page 2026-09-28 21:08:10 +00:00
build.test.js build: refuse unknown arguments; name the fix when typescript is missing 2026-09-28 14:42:33 +00:00
change-report.test.js test: read an ErrorResponse off the broker; every answer has its own messageId 2026-09-28 14:26:44 +00:00
connection.test.js bridge: publish through a Publisher; devices no longer hold the broker client 2026-09-28 19:34:39 +00:00
directives.test.js test: read an ErrorResponse off the broker; every answer has its own messageId 2026-09-28 14:26:44 +00:00
discovery.test.js registry: describe an Alexa interface as data 2026-09-28 15:18:22 +00:00
examples.test.js examples: one runnable file per recipe on the 2.0 API, the 1.x files in examples/legacy 2026-09-28 21:38:07 +00:00
packaging.test.js test: find the imports of the builds with the compiler's scanner 2026-09-28 14:44:34 +00:00
packaging.test.mjs discovery: generate capability JSON from the registry 2026-09-28 15:35:46 +00:00
readme.test.js 2.0.0 2026-09-28 22:55:23 +00:00
subscriptions.test.js bridge: subscribe to discover and +/alexaDirective only 2026-09-28 19:36:09 +00:00
transport.test.js bridge: a discovery request that arrives twice is answered once 2026-09-28 21:16:50 +00:00
typings.test.js build: dual CJS/ESM output, ES2020 target, Node 18 floor 2026-09-28 13:58:17 +00:00

"use strict";
// readme.md says what the code does: its table of interfaces is the one the registry gives, a program it shows is
// the text of a file that a test runs, and the other code blocks compile.
const { test } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { run, ROOT } = require("./helpers/examples.js");
const { table, update, shown, BEGIN, END, DRIVEN, README } = require("../scripts/capability-table.js");
const alex2node = require("..");

const readme = fs.readFileSync(README, "utf8");
const AsyncFunction = (async () => {}).constructor;

// The js code blocks, each with the file its marker line names
const blocks = [...readme.matchAll(/(?:<!-- file: (\S+) -->\n)?```js\n([\s\S]*?)\n```/g)]
  .map(([, file, code]) => ({ file, code }));
const loads = (code) => /require\("alex2node"\)|from "alex2node"/.test(code);

test("the table of interfaces and the files in the readme are the ones of the code: npm run docs", () => {
  assert.equal(update(readme), readme);
});

test("the table has a row for every described interface, and a line for the others", () => {
  const text = table();
  for (const { namespace, tier } of alex2node.registry.list()) {
    const row = text.split("\n").filter((line) => line.startsWith(`| [${namespace}](`));
    assert.equal(row.length, tier === 3 ? 0 : 1, namespace);
    assert.ok(text.includes(`[${namespace}](`), namespace);
  }
  assert.ok(readme.indexOf(BEGIN) < readme.indexOf(END));
});

test("an interface is said to be driven through Alexa only when a run is recorded for it", () => {
  // The run of 2026-09-28 with 33 test devices on the 2.0 API. A name is added here with the record of its run.
  assert.deepEqual(Object.keys(DRIVEN).sort(), [
    "Alexa", "Alexa.BrightnessController", "Alexa.ColorController", "Alexa.ColorTemperatureController",
    "Alexa.ContactSensor", "Alexa.DoorbellEventSource", "Alexa.InventoryLevelSensor", "Alexa.LockController",
    "Alexa.ModeController", "Alexa.MotionSensor", "Alexa.PercentageController", "Alexa.PowerController",
    "Alexa.PowerLevelController", "Alexa.RangeController", "Alexa.SceneController",
    "Alexa.SecurityPanelController", "Alexa.SimpleEventSource", "Alexa.TemperatureSensor",
    "Alexa.ThermostatController", "Alexa.ThermostatController.Schedule", "Alexa.ToggleController",
  ]);
  const rows = table().split("\n").filter((line) => line.startsWith("| [Alexa"));
  for (const line of rows) {
    const cells = line.split(" | ");
    const namespace = cells[0].slice(3, cells[0].indexOf("]"));
    assert.equal(cells[cells.length - 1].replace(/ \|$/, ""), DRIVEN[namespace] ?? "-", namespace);
  }
});

test("a program in the readme is a file, and a test runs the file", () => {
  assert.deepEqual(shown(readme), ["examples/lamp.js", "examples/testing/lamp.test.js", "examples/legacy/ExamplePowerController.js"]);
  // More than the line that loads the library: a program, which has to be one of the files
  const programs = blocks.filter(({ code }) => loads(code) && code.split("\n").length > 1);
  assert.deepEqual(programs.map(({ file }) => file), shown(readme));

  const examples = fs.readFileSync(path.join(__dirname, "examples.test.js"), "utf8");
  assert.ok(examples.includes('start("lamp.js"'));
  assert.ok("ExamplePowerController.js" in require("./fixtures/legacy-examples/discovery-1.5.2.json"));
  // examples/testing/lamp.test.js: the next test
});

test("examples/testing/lamp.test.js passes, without a broker", async () => {
  // LOOPBACK_PORT 1, where nothing listens: a connection would fail
  const example = await run(path.join("testing", "lamp.test.js"));
  assert.equal(await example.exited, 0, example.output());
  assert.match(example.output(), /pass 4\n/);
  assert.match(example.output(), /fail 0\n/);
});

test("the other code blocks of the readme compile, and load what the library exports", () => {
  const fragments = blocks.filter(({ file }) => !file);
  assert.ok(fragments.length >= 8);
  for (const { code } of fragments) {
    for (const [, names] of code.matchAll(/(?:const|import) \{([^}]+)\} (?:= require\("alex2node"\)|from "alex2node")/g)) {
      for (const name of names.split(",").map((part) => part.trim())) assert.ok(name in alex2node, `${name} is not an export`);
    }
    const body = code.split("\n").filter((line) => !/^import .* from "alex2node";$/.test(line)).join("\n");
    assert.doesNotThrow(() => new AsyncFunction(body), `this block does not compile:\n${code}`);
  }
});

test("what the readme calls on AlexaErrors and on the registry is there", () => {
  for (const [, helper] of readme.matchAll(/AlexaErrors\.(\w+)\(/g)) assert.equal(typeof alex2node.AlexaErrors[helper], "function", helper);
  for (const [, namespace] of readme.matchAll(/registry\.get\("([^"]+)"\)/g)) assert.ok(alex2node.registry.has(namespace), namespace);
  assert.equal(Object.keys(require("../dist/cjs/registry/catalog.js").ERROR_TYPES).length, 73);
});