Skip to content

Commit 36a6cf0

Browse files
authored
test(config): check the package files list with publint over npm's real pack list (#568)
1 parent 9c734b8 commit 36a6cf0

1 file changed

Lines changed: 58 additions & 17 deletions

File tree

‎test/config/package-json.test.ts‎

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,47 @@
44
*/
55

66
import { describe, expect, test } from "bun:test";
7-
import { readFileSync } from "node:fs";
7+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
88
import { dirname, join, resolve } from "node:path";
99
import { parseSync } from "oxc-parser";
10+
import { publint } from "publint";
11+
import { formatMessage } from "publint/utils";
1012
import { parse as parseYaml } from "yaml";
1113
import manifest from "../../.release-please-manifest.json";
1214
import pkg from "../../package.json";
1315
import tsdown from "../../tsdown.config.js";
1416
import { ROOT } from "../root.js";
1517
import { readSettingsSchema } from "../settings-schema.js";
18+
import { withTempDir } from "../temp-dir.js";
1619

1720
/** The built lib/settings.schema.json, whose $id names the repository the package must point at. */
1821
const schema = readSettingsSchema();
1922

2023
/** tsdown types its options loosely (a glob, a list, or a map); this config is the map form. */
2124
const build = tsdown as { entry: Record<string, string>; outDir: string };
2225

23-
/** The export targets and the bin paths package.json names, each as a ./-relative path. */
24-
function manifestTargets(): string[] {
26+
/** The two fields the publish checks read, so a hand-made variant of `pkg` is a manifest too. */
27+
interface Manifest {
28+
readonly exports: Readonly<Record<string, string | { types: string; default: string }>>;
29+
readonly bin: Readonly<Record<string, string>>;
30+
}
31+
32+
/** The export targets and the bin paths `manifest` names, each as a ./-relative path. */
33+
function manifestTargets(manifest: Manifest): string[] {
2534
return [
26-
...Object.values(pkg.exports).map((entry) =>
35+
...Object.values(manifest.exports).map((entry) =>
2736
typeof entry === "string" ? entry : entry.default,
2837
),
29-
...Object.values(pkg.bin).map((bin) => `./${bin}`),
38+
...Object.values(manifest.bin).map((bin) => `./${bin}`),
3039
];
3140
}
3241

42+
function manifestDeclarations(manifest: Manifest): string[] {
43+
return Object.values(manifest.exports).flatMap((entry) =>
44+
typeof entry === "string" ? [] : [entry.types],
45+
);
46+
}
47+
3348
/** The import paths the library page's entry table names, plus the two its trailing sentence says ride along, sorted. */
3449
function documentedExportPaths(page: string): string[] {
3550
const entries = page.match(/^## The two entries\n([\s\S]*?)^## /m)?.[1] ?? "";
@@ -49,11 +64,31 @@ function documentedBinNames(page: string): string[] {
4964
return [...sentence.matchAll(/`([^`]+)`/g)].map((match) => match[1] ?? "").sort();
5065
}
5166

52-
/** Whether `files` packs `target`: an entry names a file or a directory, with or without a trailing slash. */
53-
function isPacked(target: string): boolean {
54-
return pkg.files.some((entry) => {
55-
const path = `./${entry.replace(/\/$/, "")}`;
56-
return target === path || target.startsWith(`${path}/`);
67+
/**
68+
* What publint reports for `manifest` once npm, the publisher, has packed it; warnings count as errors, as lint:package
69+
* runs it. A scratch package holds the manifest and an empty stub at every file it points a consumer at (a bin stub
70+
* carries the shebang publint requires), so the judgment is the manifest's shape and npm's pack list, not the build.
71+
*/
72+
async function manifestMessages(manifest: Manifest): Promise<string[]> {
73+
return withTempDir("gsac-manifest-", async (dir) => {
74+
const bins = Object.values(manifest.bin).map((bin) => `./${bin}`);
75+
for (const file of [...manifestTargets(manifest), ...manifestDeclarations(manifest)]) {
76+
if (file === "./package.json") {
77+
continue;
78+
}
79+
mkdirSync(join(dir, dirname(file)), { recursive: true });
80+
writeFileSync(join(dir, file), bins.includes(file) ? "#!/usr/bin/env node\n" : "");
81+
}
82+
writeFileSync(join(dir, "package.json"), JSON.stringify(manifest));
83+
const { messages } = await publint({
84+
pkgDir: dir,
85+
pack: "npm",
86+
level: "warning",
87+
strict: true,
88+
});
89+
return messages.map(
90+
(message) => formatMessage(message, manifest, { color: false }) ?? message.code,
91+
);
5792
});
5893
}
5994

@@ -104,7 +139,7 @@ describe("package.json as the npm manifest", () => {
104139

105140
test("the exports map and the bins name exactly the library build, the schema, and the manifest", () => {
106141
const built = Object.keys(build.entry).map((name) => `./${build.outDir}/${name}.js`);
107-
const targets = manifestTargets();
142+
const targets = manifestTargets(pkg);
108143
expect(targets.filter((target) => !built.includes(target)).sort()).toEqual([
109144
"./lib/settings.schema.json",
110145
"./package.json",
@@ -131,20 +166,26 @@ describe("package.json as the npm manifest", () => {
131166
);
132167
}
133168
}
134-
const declarations: string[] = [];
135169
for (const entry of Object.values(pkg.exports)) {
136170
if (typeof entry === "string") {
137171
continue;
138172
}
139173
expect(entry.types).toBe(entry.default.replace(/\.js$/, ".d.ts"));
140-
declarations.push(entry.types);
141-
}
142-
// npm packs `files` (and the manifest, always), so a target outside them installs as a dangling path.
143-
for (const target of [...targets, ...declarations].filter((t) => t !== "./package.json")) {
144-
expect(isPacked(target), target).toBe(true);
145174
}
146175
});
147176

177+
test("npm's pack list holds every file the manifest points at, and publint has nothing else to say", async () => {
178+
// npm packs `files`, the manifest, README, LICENSE, and every bin, so a target outside them installs as a dangling path.
179+
expect(await manifestMessages(pkg)).toEqual([]);
180+
});
181+
182+
test("an export target outside `files` is reported as unpublished (negative control)", async () => {
183+
const manifest = { ...pkg, exports: { ...pkg.exports, "./extra": "./dist/extra.js" } };
184+
expect(await manifestMessages(manifest)).toEqual([
185+
'pkg.exports["./extra"] is ./dist/extra.js but the file is not published. Is it specified in pkg.files?',
186+
]);
187+
});
188+
148189
test("the runtime dependencies are exactly the packages the library entries import", () => {
149190
// tsdown externalizes `dependencies`, so the consumer's package manager installs them beside lib/pkg/; the action bundle inlines everything,
150191
// so a package only src/action/ or src/main.ts uses (@actions/core, @actions/artifact) stays a devDependency.

0 commit comments

Comments
 (0)