examples: one runnable file per recipe on the 2.0 API, the 1.x files in examples/legacy

Ten recipes: lamp, colour lamp, thermostat, blind, lock (deferred), contact
sensor (ChangeReport), scene, doorbell (raise), typed errors, and a plug as an
ES module. Each reads ALEX2MQTT_USERNAME, _PASSWORD and _ROOT_TOPIC and names
the ones that are missing. The nine examples of 1.5.2 move to examples/legacy
unchanged.

test/examples.test.js starts every file with node, its broker connection sent
to a broker on 127.0.0.1 (test/helpers/loopback.js), discovers the device,
sends directives and checks the answers. A legacy file is started and its
discovery compared with the answer the 1.5.2 build gave, recorded in
test/fixtures/legacy-examples.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
David 2026-09-28 21:38:07 +00:00
parent 2cbcde563e
commit 8379a65d78
25 changed files with 1705 additions and 0 deletions

53
test/helpers/examples.js Normal file
View file

@ -0,0 +1,53 @@
"use strict";
// Runs a file of examples/ as its reader does, with node, in a process of its own. The process gets the environment
// the test names and nothing else, a directory without a .env file, and test/helpers/loopback.js, which sends its
// broker connection to the broker of the test.
const { spawn } = require("node:child_process");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { cleanup, until } = require("./harness.js");
const ROOT = path.join(__dirname, "..", "..");
const LOOPBACK = path.join(__dirname, "loopback.js");
/**
* Start examples/<file>. broker is one of harness.broker(); without it the example can connect nowhere. Resolves
* with { child, output, write, exited, stop } once the example has subscribed to <root>/discover or, without a
* root, at once. output() is what it wrote to stdout and stderr so far.
*/
async function run(file, { broker, root, env = {}, directory = path.join(ROOT, "examples") } = {}) {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "alex2node-example-"));
let subscribed = false;
const onSubscribe = (subscriptions) => {
if (subscriptions.some(({ topic }) => topic === `${root}/discover` || topic === `${root}/#`)) subscribed = true;
};
if (broker && root) broker.aedes.on("subscribe", onSubscribe);
const child = spawn(process.execPath, ["--require", LOOPBACK, path.join(directory, file)], {
cwd,
env: { PATH: process.env.PATH, LOOPBACK_PORT: String(broker ? broker.port : 1), ...env },
stdio: ["pipe", "pipe", "pipe"],
});
let text = "";
child.stdout.on("data", (chunk) => { text += chunk; });
child.stderr.on("data", (chunk) => { text += chunk; });
const exited = new Promise((resolve) => child.once("exit", (code) => resolve(code)));
const stop = async () => {
if (broker && root) broker.aedes.off("subscribe", onSubscribe);
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
await exited;
fs.rmdirSync(cwd);
};
cleanup(stop);
const started = { child, output: () => text, write: (line) => child.stdin.write(line), exited, stop };
if (broker && root) {
await until(() => subscribed || child.exitCode !== null, 10000, `${file} to subscribe`);
broker.aedes.off("subscribe", onSubscribe);
if (!subscribed) throw new Error(`${file} ended before it subscribed:\n${text}`);
}
return started;
}
module.exports = { run, ROOT };