build: dual CJS/ESM output, ES2020 target, Node 18 floor
"exports" gains "import" and "types" next to "require": import "alex2node" failed with ERR_PACKAGE_PATH_NOT_EXPORTED. scripts/build.mjs compiles src/ to dist/cjs, dist/esm and dist/types in a staging directory and swaps them in only when both compiler runs passed. "main" and "types" follow for resolvers that do not read "exports". Target ES6 -> ES2020, "engines": node >= 18, relative imports carry ".js" for Node's ESM loader. uuid gives way to crypto.randomUUID(); mqtt is the only runtime dependency left. Tests load the package by name. New: both entry points, the modules the builds import, dist/ equal to a fresh build, a round trip on the ES module build, and two compiled type fixtures in place of the .d.ts regexes. 22 tests pass in 5.7 s. dist/: 16 files, 79,711 B -> 25 files, 132,291 B; the npm tarball is 25,125 B. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
58869d44c1
commit
1d88c7811a
50 changed files with 1472 additions and 195 deletions
|
|
@ -3,7 +3,7 @@
|
|||
// Alexa event gateway.
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController } = require("../dist/index.js");
|
||||
const { Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController } = require("alex2node");
|
||||
const { setup, until } = require("./helpers/harness.js");
|
||||
|
||||
test("a change report carries the changed properties in payload.change and the rest in context", async () => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// 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 { Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController, DEFAULT_HOST } = require("alex2node");
|
||||
const { broker, setup, connected, directive, until, sleep, cleanup } = require("./helpers/harness.js");
|
||||
|
||||
// Nothing listens on port 1, so every attempt is refused at once.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ const { test } = require("node:test");
|
|||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController, EndpointHealth, TemperatureSensorScale,
|
||||
} = require("../dist/index.js");
|
||||
} = require("alex2node");
|
||||
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 %".
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// per registered device.
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Alex2MQTT, AlexaInterfaceType, DisplayCategory } = require("../dist/index.js");
|
||||
const { Alex2MQTT, AlexaInterfaceType, DisplayCategory } = require("alex2node");
|
||||
const { setup, connected, until } = require("./helpers/harness.js");
|
||||
|
||||
// Ask as Alex2MQTT does and return the endpoints of the one answer.
|
||||
|
|
|
|||
11
test/fixtures/tsconfig.json
vendored
Normal file
11
test/fixtures/tsconfig.json
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"strict": true,
|
||||
"skipLibCheck": false,
|
||||
"noEmit": true
|
||||
},
|
||||
"files": ["types.ts", "types.mts"]
|
||||
}
|
||||
10
test/fixtures/types.mts
vendored
Normal file
10
test/fixtures/types.mts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// Compiled, never run: the same declarations as seen from an ES module. Without a "types" condition in "exports" the
|
||||
// import resolves to dist/esm/index.js, which has no declarations next to it, and this file fails with TS7016.
|
||||
import { Alex2MQTT, DEFAULT_HOST, EndpointHealth } from "alex2node";
|
||||
import type { Device } from "alex2node";
|
||||
|
||||
const bridge = new Alex2MQTT("user", "password", "root", false, { host: DEFAULT_HOST });
|
||||
const devices: Device[] = bridge.getDevices();
|
||||
const health: EndpointHealth = EndpointHealth.OK;
|
||||
|
||||
export { devices, health };
|
||||
36
test/fixtures/types.ts
vendored
Normal file
36
test/fixtures/types.ts
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Compiled, never run (test/typings.test.js, npm run check): a CommonJS TypeScript consumer of the declarations in
|
||||
// dist/types, resolved through the package's "exports". A "@ts-expect-error" line fails the compile when the
|
||||
// declaration stops rejecting what follows it.
|
||||
import { ActionMapping, AlexaActions, AlexaInterfaceType, DisplayCategory, EndpointHealth, PowerController } from "alex2node";
|
||||
import type { Alex2MQTT, Alex2MQTTOptions, AlexaInterface, AlexaStatusMessage, ChangeCause, Device, SupportedMode } from "alex2node";
|
||||
|
||||
declare const bridge: Alex2MQTT;
|
||||
declare const message: AlexaStatusMessage;
|
||||
declare const capability: AlexaInterface;
|
||||
|
||||
const options: Alex2MQTTOptions = { host: "mqtt://127.0.0.1:1883", mqtt: { reconnectPeriod: 1000 }, log: (line) => line.length };
|
||||
const device: Device = bridge.registerDevice("Lamp", "lamp-1", [DisplayCategory.LIGHT, DisplayCategory.SWITCH]);
|
||||
const added: AlexaInterface = device.addCapability(AlexaInterfaceType.POWER_CONTROLLER, { proactivelyReported: true });
|
||||
|
||||
// ActionMapping: the payload is optional
|
||||
const close = new ActionMapping([AlexaActions.Close], "TurnOn");
|
||||
capability.addActionMapping(close);
|
||||
|
||||
// addHealthProp: the enum or its string values, chainable
|
||||
const chained: AlexaStatusMessage = message.addHealthProp(EndpointHealth.OK).addHealthProp("UNREACHABLE", 50);
|
||||
// @ts-expect-error not a connectivity value
|
||||
message.addHealthProp("ASLEEP");
|
||||
// @ts-expect-error a power state is ON or OFF
|
||||
message.addPowerControllerProp("ON ");
|
||||
message.addPowerControllerProp(PowerController.ON);
|
||||
|
||||
// addSupportedModes: Mode objects, plain strings still accepted
|
||||
const mode: SupportedMode = { value: "Fan.Auto", modeResources: { friendlyNames: [{ "@type": "text", value: { text: "Auto", locale: "en-US" } }] } };
|
||||
capability.addSupportedModes([mode, "Fan.On"]);
|
||||
|
||||
const cause: ChangeCause = "PHYSICAL_INTERACTION";
|
||||
const sent: Promise<string> = device.getChangeReport(cause).addPowerControllerProp(PowerController.OFF).send();
|
||||
// @ts-expect-error not a cause Alexa knows
|
||||
device.getChangeReport("BUTTON");
|
||||
|
||||
export { options, added, chained, sent };
|
||||
69
test/packaging.test.js
Normal file
69
test/packaging.test.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"use strict";
|
||||
// The package as a consumer resolves it. A package can load itself by name through its own "exports", so
|
||||
// require("alex2node") and import("alex2node") below take the path an installed copy takes.
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { builtinModules } = require("node:module");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const pkg = require("alex2node/package.json");
|
||||
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
const DIST = path.join(ROOT, "dist");
|
||||
|
||||
// Every file under dir, relative to it, sorted. A staging directory left by a killed build is not part of dist/.
|
||||
function files(dir, prefix = "") {
|
||||
return fs.readdirSync(path.join(dir, prefix), { withFileTypes: true })
|
||||
.filter((entry) => !entry.name.startsWith(".build-"))
|
||||
.flatMap((entry) => (entry.isDirectory() ? files(dir, path.join(prefix, entry.name)) : [path.join(prefix, entry.name)]))
|
||||
.sort();
|
||||
}
|
||||
|
||||
test("require(\"alex2node\") loads the CommonJS build", () => {
|
||||
assert.equal(require.resolve("alex2node"), path.join(DIST, "cjs", "index.js"));
|
||||
assert.equal(typeof require("alex2node").Alex2MQTT, "function");
|
||||
});
|
||||
|
||||
test("import(\"alex2node\") loads the ES module build, with the export names of the CommonJS build", async () => {
|
||||
const cjs = require("alex2node");
|
||||
const esm = await import("alex2node");
|
||||
// Had the import reached the CommonJS file, the namespace would carry "default" and the classes would be identical.
|
||||
assert.deepEqual(Object.keys(esm).sort(), Object.keys(cjs).sort());
|
||||
assert.notEqual(esm.Alex2MQTT, cjs.Alex2MQTT);
|
||||
assert.equal(esm.DEFAULT_HOST, cjs.DEFAULT_HOST);
|
||||
});
|
||||
|
||||
test("\"main\", \"module\" and \"types\" name the same files as \"exports\", for resolvers that do not read it", () => {
|
||||
const entry = pkg.exports["."];
|
||||
assert.deepEqual([pkg.main, pkg.module, pkg.types], [entry.require, entry.import, entry.types]);
|
||||
for (const file of [pkg.main, pkg.module, pkg.types]) assert.ok(fs.existsSync(path.join(ROOT, file)), file);
|
||||
assert.equal(require(path.join(ROOT, pkg.main)), require("alex2node"));
|
||||
});
|
||||
|
||||
test("the builds import Node built-ins and the declared dependencies, nothing else", () => {
|
||||
// "mqtt/lib/x" and "@scope/name/x" belong to the packages "mqtt" and "@scope/name"
|
||||
const packageOf = (specifier) => specifier.split("/").slice(0, specifier.startsWith("@") ? 2 : 1).join("/");
|
||||
const imported = new Set();
|
||||
for (const file of files(DIST).filter((f) => f.endsWith(".js"))) {
|
||||
const source = fs.readFileSync(path.join(DIST, file), "utf8");
|
||||
for (const [, specifier] of source.matchAll(/(?:\brequire\(|\bfrom\s+|\bimport\s+)"([^".][^"]*)"/g)) {
|
||||
if (!builtinModules.includes(specifier.replace(/^node:/, ""))) imported.add(packageOf(specifier));
|
||||
}
|
||||
}
|
||||
assert.deepEqual([...imported].sort(), Object.keys(pkg.dependencies).sort());
|
||||
});
|
||||
|
||||
test("dist/ is what npm run build produces from src/", () => {
|
||||
const fresh = fs.mkdtempSync(path.join(os.tmpdir(), "alex2node-build-"));
|
||||
try {
|
||||
const build = spawnSync(process.execPath, [path.join(ROOT, "scripts", "build.mjs"), "--out", fresh], { encoding: "utf8" });
|
||||
assert.equal(build.status, 0, `the build failed:\n${build.stdout}${build.stderr}`);
|
||||
assert.deepEqual(files(DIST), files(fresh), "dist/ has other files than a build writes: npm run build");
|
||||
const stale = files(fresh).filter((f) => !fs.readFileSync(path.join(DIST, f)).equals(fs.readFileSync(path.join(fresh, f))));
|
||||
assert.deepEqual(stale, [], "dist/ is older than src/: npm run build");
|
||||
} finally {
|
||||
fs.rmSync(fresh, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
39
test/packaging.test.mjs
Normal file
39
test/packaging.test.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// The ES module build, imported the way an ES module consumer writes it, on the broker: a build that loads but
|
||||
// cannot reach mqtt or one of its own files fails here.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Alex2MQTT, AlexaInterfaceType, DisplayCategory, EndpointHealth, PowerController } from "alex2node";
|
||||
import harness from "./helpers/harness.js";
|
||||
|
||||
const { setup, directive, until } = harness;
|
||||
|
||||
test("import { Alex2MQTT } from \"alex2node\": discovery, a directive and a change report on the ES module build", async () => {
|
||||
const { alexa, bridge } = await setup(Alex2MQTT, "root");
|
||||
const lamp = bridge.registerDevice("Lamp", "lamp-1", DisplayCategory.LIGHT);
|
||||
lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER, { proactivelyReported: true });
|
||||
lamp.addCapability(AlexaInterfaceType.ENDPOINT_HEALTH);
|
||||
lamp.on("Event", (request) => {
|
||||
lamp.getStatusMessage(request.header.correlationToken, true)
|
||||
.addHealthProp(EndpointHealth.OK)
|
||||
.addPowerControllerProp(PowerController.ON)
|
||||
.send();
|
||||
});
|
||||
|
||||
alexa.publish("root/discover", { namespace: "Alexa.Discovery", name: "Discover" });
|
||||
await until(() => alexa.on("root/discover_r").length === 1, 3000, "discovery reply");
|
||||
const [endpoints] = alexa.on("root/discover_r");
|
||||
assert.deepEqual(endpoints.map((e) => e.endpointId), ["lamp-1"]);
|
||||
assert.deepEqual(endpoints[0].capabilities.map((c) => c.interface), ["Alexa.PowerController", "Alexa.EndpointHealth"]);
|
||||
|
||||
alexa.send("root", directive("Alexa.PowerController", "TurnOn", "lamp-1", "ct1"));
|
||||
await until(() => alexa.on("root/lamp-1/alexaResponce").length === 1, 3000, "directive response");
|
||||
const [response] = alexa.on("root/lamp-1/alexaResponce");
|
||||
assert.equal(response.event.header.name, "Response");
|
||||
assert.equal(response.event.header.correlationToken, "ct1");
|
||||
// crypto.randomUUID() writes the messageId: a version 4 UUID, as uuid's v4() did
|
||||
assert.match(response.event.header.messageId, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
|
||||
assert.equal(await lamp.getChangeReport().addPowerControllerProp(PowerController.OFF).send(), "root/changeReport");
|
||||
await until(() => alexa.on("root/changeReport").length === 1, 3000, "change report");
|
||||
assert.equal(alexa.on("root/changeReport")[0].event.payload.change.properties[0].value, "OFF");
|
||||
});
|
||||
|
|
@ -1,28 +1,20 @@
|
|||
"use strict";
|
||||
// The declarations tracked in dist/: what a TypeScript consumer compiles against.
|
||||
// The declarations in dist/types: 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 { spawnSync } = require("node:child_process");
|
||||
const { ActionMapping, AlexaActions, AlexaStatusMessage, EndpointHealth } = require("alex2node");
|
||||
|
||||
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");
|
||||
const FIXTURES = path.join(__dirname, "fixtures");
|
||||
|
||||
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";/);
|
||||
test("the declarations compile for a CommonJS and an ES module consumer (test/fixtures/types.ts, types.mts)", () => {
|
||||
const tsc = require.resolve("typescript/bin/tsc");
|
||||
const run = spawnSync(process.execPath, [tsc, "-p", path.join(FIXTURES, "tsconfig.json")], { encoding: "utf8" });
|
||||
assert.equal(run.status, 0, `tsc reported:\n${run.stdout}${run.stderr}`);
|
||||
});
|
||||
|
||||
test("what the declarations promise holds at run time: ActionMapping without a payload, addHealthProp with the enum or the string", () => {
|
||||
assert.deepEqual(new ActionMapping([AlexaActions.Close], "TurnOn").toJSON(), {
|
||||
"@type": "ActionsToDirective",
|
||||
actions: ["Alexa.Actions.Close"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue