diff --git a/test/packaging/entrypoints.test.ts b/test/packaging/entrypoints.test.ts new file mode 100644 index 0000000..92478e4 --- /dev/null +++ b/test/packaging/entrypoints.test.ts @@ -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; + scripts: Record; +} + +const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); + +const readJSON = (name: string): T => + JSON.parse(readFileSync(join(repoRoot, name), "utf-8")) as T; + +const tsconfig = readJSON("tsconfig.json"); +const pkg = readJSON("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"); + }); +});