// 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 the suite and the lint container but // never the build, so `tsconfig.json` and `package.json` were free to drift // apart. (The formatting check is part of the lint container, not a step of // its own; `test/packaging/lint-once.test.ts` is what holds that shape.) 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"); }); });