The test that compares the imports of dist/ with "dependencies" matched a regular expression against the whole
file. tsc keeps comments in its output, so a usage line in a doc comment (import { ActionMapping } from
"alex2node") counted as an import and failed the commit gate with a message about dependencies.
ts.preProcessFile lists the imports instead. Stripping comments with two more expressions was tried first and
reads strings wrong: in `"src/*.ts"; require("real-one"); "*/"` it drops the require, and it never saw
import("lazy-one"); the scanner returns both. The declarations are scanned too: a .d.ts that imports a package the
consumer does not get breaks the consumer's type check.
Checked in a scratch copy: a doc comment and a string with an import in them pass; require("left-pad") in
dist/cjs, an aedes import in a declaration and an unused declared dependency each fail. npm test: 30 pass in
8.9-10.0 s.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
84 lines
4.6 KiB
JavaScript
84 lines
4.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 ts = require("typescript");
|
|
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.
|
|
function files(dir, prefix = "") {
|
|
return fs.readdirSync(path.join(dir, prefix), { withFileTypes: true })
|
|
.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.default, entry.import.default, entry.require.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("each build has its own declarations, and \"exports\" names them before the code", () => {
|
|
for (const [condition, dir] of [["require", "types"], ["import", "esm"]]) {
|
|
const target = pkg.exports["."][condition];
|
|
// TypeScript takes the first condition that matches, so "types" after "default" is never read
|
|
assert.deepEqual(Object.keys(target), ["types", "default"], condition);
|
|
assert.equal(target.types, `./dist/${dir}/index.d.ts`, condition);
|
|
const declared = files(path.join(DIST, dir)).filter((f) => f.endsWith(".d.ts")).map((f) => f.replace(/\.d\.ts$/, ".js"));
|
|
const built = files(path.join(ROOT, path.dirname(target.default))).filter((f) => f.endsWith(".js"));
|
|
assert.deepEqual(declared, built, `a declaration for every file of the ${condition} build`);
|
|
}
|
|
});
|
|
|
|
test("the builds and their declarations 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) => /\.(js|ts)$/.test(f))) {
|
|
// Read with the compiler's scanner: tsc keeps comments in its output, and an import in a usage example or in
|
|
// a string is not an import.
|
|
const { importedFiles } = ts.preProcessFile(fs.readFileSync(path.join(DIST, file), "utf8"), true, true);
|
|
for (const { fileName: specifier } of importedFiles) {
|
|
if (specifier.startsWith(".") || builtinModules.includes(specifier.replace(/^node:/, ""))) continue;
|
|
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 });
|
|
}
|
|
});
|