Alex2Node/test/packaging.test.js
David 1d88c7811a 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>
2026-09-28 13:58:17 +00:00

69 lines
3.6 KiB
JavaScript

"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 });
}
});