All checks were successful
check / check (push) Successful in 35s
The header of test/packaging/lint-once.test.ts claimed a duplicate prettier pass is caught wherever it is added. It was not: the walk started at `make check`, which never reads `Dockerfile`, so appending `RUN yarn run prettier --check .` to the image that `script/cibuild` builds left the suite green — two prettier passes on the one path where it matters most. The walk now also starts at `.gitea/workflows/check.yml` and follows its `run:` steps into `script/cibuild` and from there into both images, so the graph under test is the one CI executes rather than the one it was assumed to execute. Reaching `script/cibuild` and `Dockerfile` is asserted, and the test and build image is asserted to invoke prettier zero times. The lockfile assertion was a substring check against the whole of `script/bootstrap`. That script has two install sites, and the containers take the second, because the pinned node image ships yarn; changing that site to a bare `yarn install` kept the suite green while the container's install stopped being pinned. `install_js_deps` is now resolved out of the script and split at its `missing yarn` guard, and every `yarn install` occurrence in each branch is required to carry `--frozen-lockfile`. That the container runs `script/bootstrap` at all is asserted too, so the lockfile assertions cannot end up describing a script the image never executes. Prettier is counted per occurrence instead of per line: `prettier --check . && prettier --check src` was one invocation by the old count. The `continue` that followed a counted line also dropped every script, make, yarn and docker edge sharing that line, so a subtree could be hidden behind a single `&&`; edges are now extracted from every line. Undercounting is what would make this file worthless, so every way of reaching nothing is a thrown error rather than a quiet zero: an unknown Makefile target, an unknown package.json script, a missing script file, a node that resolves to no commands, and an unknown node kind. All five are tested, as is a walk that legitimately counts zero, and the cycle guard. Every assertion in the file was mutation-tested: changed to assert something else, run, and confirmed to fail for its own named reason. The two mutations above were reproduced and both now turn the suite red. test/packaging/entrypoints.test.ts said `make check` runs test, lint and fmt-check. Formatting has been part of the lint container since the duplicate host pass was removed, so the comment now says what it does.
119 lines
4.9 KiB
TypeScript
119 lines
4.9 KiB
TypeScript
// 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<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");
|
|
});
|
|
});
|