Compile bin/ alongside src/ so the package can be built (closes #3) #26
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