Add failing tests for the package entrypoint contract
The manifests disagreed and nothing noticed. tsconfig.json set rootDir to ./src while include also matched bin/**/*, which is TS6059, so no build had succeeded; package.json meanwhile advertised main, types and a bin that a successful build would have to produce. make check runs test, lint and fmt-check, so neither half was ever exercised. These tests read tsconfig.json and package.json and assert the contract between them without invoking a compiler, which keeps them in the fast unit suite: every include pattern must root under rootDir, and main, types and bin.quak must equal the paths tsc will emit for src/index.ts and bin/quak.ts. They also require a quak script pointing at the built CLI, which the README's Getting Started block has always told the reader to run. Three of them fail at this commit.
This commit is contained in:
114
test/packaging/entrypoints.test.ts
Normal file
114
test/packaging/entrypoints.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// The package manifest promises three files that only exist after a build:
|
||||||
|
// `main`, `types`, and the `quak` binary. Nothing in the test suite used to
|
||||||
|
// look at them, and `make check` runs test, lint and fmt-check but never the
|
||||||
|
// build, so `tsconfig.json` and `package.json` were free to drift apart. They
|
||||||
|
// did: `rootDir` was `./src` while `include` also pulled in `bin/**/*`, which
|
||||||
|
// is TS6059, and no build had succeeded for as long as that was true.
|
||||||
|
//
|
||||||
|
// These tests read both files and check the contract between them, without
|
||||||
|
// running a build, so they stay in the fast unit suite. The complementary
|
||||||
|
// check — that the files really landed on disk — is in `script/build`, which
|
||||||
|
// runs after the compiler and is the only place that can honestly answer it.
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { join, posix } from "node:path";
|
||||||
|
|
||||||
|
interface TsConfig {
|
||||||
|
compilerOptions: {
|
||||||
|
outDir: string;
|
||||||
|
rootDir: string;
|
||||||
|
};
|
||||||
|
include: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PackageJson {
|
||||||
|
main: string;
|
||||||
|
types: string;
|
||||||
|
bin: Record<string, string>;
|
||||||
|
scripts: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
|
||||||
|
|
||||||
|
const readJSON = <T>(name: string): T =>
|
||||||
|
JSON.parse(readFileSync(join(repoRoot, name), "utf-8")) as T;
|
||||||
|
|
||||||
|
const tsconfig = readJSON<TsConfig>("tsconfig.json");
|
||||||
|
const pkg = readJSON<PackageJson>("package.json");
|
||||||
|
|
||||||
|
// Paths in the two manifests are written with a leading "./"; normalize so
|
||||||
|
// they can be compared and joined. Everything here is POSIX-style because
|
||||||
|
// that is what both JSON files contain, regardless of the host OS.
|
||||||
|
const clean = (p: string): string => posix.normalize(p);
|
||||||
|
const outDir = clean(tsconfig.compilerOptions.outDir);
|
||||||
|
const rootDir = clean(tsconfig.compilerOptions.rootDir);
|
||||||
|
|
||||||
|
// The directory prefix of a glob: the part before the first segment
|
||||||
|
// containing a wildcard. "src/**/*" -> "src", "bin/**/*" -> "bin".
|
||||||
|
const globRoot = (pattern: string): string => {
|
||||||
|
const segments = clean(pattern).split("/");
|
||||||
|
const wildcard = segments.findIndex((s) => s.includes("*"));
|
||||||
|
return segments.slice(0, wildcard === -1 ? segments.length : wildcard)
|
||||||
|
.join("/") || ".";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Where tsc will write the output for a source file: the path relative to
|
||||||
|
// rootDir, re-rooted under outDir, with the extension swapped.
|
||||||
|
const emitted = (source: string, extension: string): string =>
|
||||||
|
"./" +
|
||||||
|
posix.join(outDir, posix.relative(rootDir, clean(source))).replace(
|
||||||
|
/\.ts$/,
|
||||||
|
extension,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("tsconfig include and rootDir", () => {
|
||||||
|
// TS6059 is not a style complaint: tsc refuses to emit anything at all
|
||||||
|
// when a compiled file sits outside rootDir, so this single mismatch
|
||||||
|
// took out both the library and the CLI artifacts.
|
||||||
|
it("compiles only files that live under rootDir", () => {
|
||||||
|
for (const pattern of tsconfig.include) {
|
||||||
|
const root = globRoot(pattern);
|
||||||
|
const relative = posix.relative(rootDir, root);
|
||||||
|
expect(
|
||||||
|
relative === "" || !relative.startsWith(".."),
|
||||||
|
`include pattern ${pattern} matches files outside rootDir ` +
|
||||||
|
`${rootDir}; tsc rejects that with TS6059`,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("package.json entrypoints", () => {
|
||||||
|
// Each of these is the path a consumer resolves — `import { Client } from
|
||||||
|
// "quak"` for main, the editor for types, `npx quak` for the bin — so a
|
||||||
|
// wrong value is a broken package even when the build itself is green.
|
||||||
|
it("names the file tsc emits for src/index.ts as main", () => {
|
||||||
|
expect(pkg.main).toBe(emitted("src/index.ts", ".js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the declaration tsc emits for src/index.ts as types", () => {
|
||||||
|
expect(pkg.types).toBe(emitted("src/index.ts", ".d.ts"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the file tsc emits for bin/quak.ts as the quak binary", () => {
|
||||||
|
expect(pkg.bin.quak).toBe(emitted("bin/quak.ts", ".js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// The README's Getting Started block tells the reader to run
|
||||||
|
// `yarn quak login` straight after `yarn build`. That only works if a
|
||||||
|
// `quak` script exists and points at the built CLI, not at the source.
|
||||||
|
it("runs the built CLI from the quak script", () => {
|
||||||
|
expect(pkg.scripts.quak).toBeDefined();
|
||||||
|
expect(pkg.scripts.quak).toContain(pkg.bin.quak);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bin/quak.ts", () => {
|
||||||
|
// tsc copies the shebang into the emitted file, so it has to be in the
|
||||||
|
// source for the installed binary to be directly executable.
|
||||||
|
it("starts with a node shebang", () => {
|
||||||
|
const source = readFileSync(join(repoRoot, "bin/quak.ts"), "utf-8");
|
||||||
|
expect(source.split("\n")[0]).toBe("#!/usr/bin/env node");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user